lexxy-variables 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +18 -0
- data/README.md +280 -0
- data/app/views/lexxy_variables/_prompt.html.erb +27 -0
- data/config/importmap.rb +3 -0
- data/lib/lexxy-variables.rb +4 -0
- data/lib/lexxy_variables/attachable.rb +17 -0
- data/lib/lexxy_variables/attachment_type.rb +25 -0
- data/lib/lexxy_variables/configuration.rb +117 -0
- data/lib/lexxy_variables/engine.rb +29 -0
- data/lib/lexxy_variables/helper.rb +25 -0
- data/lib/lexxy_variables/pipeline.rb +97 -0
- data/lib/lexxy_variables/placeholder.rb +16 -0
- data/lib/lexxy_variables/registry.rb +24 -0
- data/lib/lexxy_variables/renderers/liquid.rb +30 -0
- data/lib/lexxy_variables/renderers/substitution.rb +15 -0
- data/lib/lexxy_variables/version.rb +3 -0
- data/lib/lexxy_variables.rb +128 -0
- data/vendor/javascript/lexxy_variables.js +122 -0
- data/vendor/stylesheets/lexxy_variables.css +115 -0
- metadata +166 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8513d081ebde943d76aefe2150f1c9fc8cd4d5477b0333ea0caa057b73a02992
|
|
4
|
+
data.tar.gz: b7cc4682a733783bfb1e68d9b6f01d7ac52f0459e4a6de77c461e59d92bc266c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6f45333d30271a85bdbf32e319dd7d43d923f7d72fe9c8aabb6373a6174ce5218df87321dde0b38021e945134fa6a5de708aefb4a21b1d40a2c6b17ae90df228
|
|
7
|
+
data.tar.gz: 6af8e6bdb3c0cc8a1cba9c18e76fdd36772d219cfc3da67bc87e7c7bec150ed4ef997cb6272aed108da0465d052984cbb7630e76f9d110fbe63a0f03dff382b9
|
data/LICENSE
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Copyright (c) 2026 Andrew Quinn
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
4
|
+
this software and associated documentation files (the "Software"), to deal in
|
|
5
|
+
the Software without restriction, including without limitation the rights to
|
|
6
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
|
7
|
+
of the Software, and to permit persons to whom the Software is furnished to do
|
|
8
|
+
so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
15
|
+
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
16
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
17
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
18
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
# lexxy-variables
|
|
2
|
+
|
|
3
|
+
Insert and safely resolve variables in [Lexxy](https://github.com/basecamp/lexxy)
|
|
4
|
+
rich text. The gem gives you an editor button (and a `{{` prompt) for inserting
|
|
5
|
+
variables into your text. Each variable is stored as an [Action Text
|
|
6
|
+
attachment](https://guides.rubyonrails.org/action_text_overview.html#rendering-attachments)
|
|
7
|
+
— an `<action-text-attachment>` chip with its own content type — not as literal
|
|
8
|
+
`{{ var_name }}` markup. At render time the gem resolves each chip to its `value`.
|
|
9
|
+
You decide what variables exist and what they turn into.
|
|
10
|
+
|
|
11
|
+
Because variables are just Action Text attachments, you can register new
|
|
12
|
+
chip types with `register_attachment` (see [Full configuration](#full-configuration)):
|
|
13
|
+
a `:value` chip resolves to an escaped string, a `:fragment` chip splices rich
|
|
14
|
+
content in before sanitization.
|
|
15
|
+
|
|
16
|
+
Liquid is **optional**. The default renderer is plain, injection-safe string
|
|
17
|
+
substitution and pulls in no template engine.
|
|
18
|
+
|
|
19
|
+
<p align="center">
|
|
20
|
+
<img src="docs/images/prompt-popup.png" alt="The {{ prompt listing variables inside the Lexxy editor" width="606">
|
|
21
|
+
</p>
|
|
22
|
+
|
|
23
|
+
## Requirements
|
|
24
|
+
|
|
25
|
+
Ruby 3.2+, Rails 8.0+, and a JavaScript bundler (esbuild, vite, or webpack).
|
|
26
|
+
See the note on importmap in [Install](#install).
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
Ruby:
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
# Gemfile
|
|
34
|
+
gem "lexxy-variables"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
JavaScript. The editor extension is distributed as an npm package, so you need a
|
|
38
|
+
bundler (esbuild, vite, webpack). Install it alongside Lexxy:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
yarn add lexxy-variables @37signals/lexxy
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Then register the extension in your JavaScript entrypoint:
|
|
45
|
+
|
|
46
|
+
```js
|
|
47
|
+
import VariableExtension from "lexxy-variables"
|
|
48
|
+
import * as Lexxy from "@37signals/lexxy"
|
|
49
|
+
|
|
50
|
+
Lexxy.configure({ global: { extensions: [ VariableExtension ] } })
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The extension imports a few primitives from `lexical`, and Lexical is very
|
|
54
|
+
sensitive to running as a single instance. If your bundle ends up with two
|
|
55
|
+
copies of `lexical` (a common one: your app pulls a newer `lexical` than the
|
|
56
|
+
version Lexxy bundles), commands dispatched from one instance won't see nodes
|
|
57
|
+
from the other and inserts fail silently. If you hit that, pin `lexical` to the
|
|
58
|
+
version Lexxy bundles so everything dedupes to one copy:
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
# match the lexical Lexxy depends on (0.44.x for Lexxy 0.9.22)
|
|
62
|
+
yarn add lexical@0.44.0
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
> **importmap is not supported yet.** The extension needs `lexical` and
|
|
66
|
+
> `@37signals/lexxy` resolved to the same instance Lexxy runs, which importmap
|
|
67
|
+
> can't currently guarantee. The gem still ships the vendored JS and pins so it
|
|
68
|
+
> works the moment Lexxy exposes Lexical to extensions upstream
|
|
69
|
+
> ([basecamp/lexxy#1047](https://github.com/basecamp/lexxy/pull/1047)). Until
|
|
70
|
+
> then, use a bundler.
|
|
71
|
+
|
|
72
|
+
## Minimal configuration
|
|
73
|
+
|
|
74
|
+
`catalog` is the list users pick from in the editor. `assigns` is the lookup that
|
|
75
|
+
turns a key into a value at render time. `catalog` is required; `assigns` is
|
|
76
|
+
optional. Leave it out and the gem reads `value` straight off the catalog item.
|
|
77
|
+
|
|
78
|
+
Put the `configure` block in an initializer, e.g. `config/initializers/lexxy_variables.rb`:
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
LexxyVariables.configure do |c|
|
|
82
|
+
c.catalog = [ { key: "company", name: "Company", value: "Acme" } ]
|
|
83
|
+
end
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The gem adds two view helpers, one for each side of the workflow: one to author
|
|
87
|
+
content and one to display it.
|
|
88
|
+
|
|
89
|
+
On the **editor page** (the form where content is composed), render the prompt
|
|
90
|
+
*inside* the Lexxy editor. The editor extension looks for the `<lexxy-prompt>`
|
|
91
|
+
within the `<lexxy-editor>` element, so it must be nested in the `rich_text_area`
|
|
92
|
+
block — that is what feeds the `{{` popup and the toolbar dropdown:
|
|
93
|
+
|
|
94
|
+
```erb
|
|
95
|
+
<%= form_with model: @record do |form| %>
|
|
96
|
+
<%= form.rich_text_area :body do %>
|
|
97
|
+
<%= lexxy_variables_prompt %>
|
|
98
|
+
<% end %>
|
|
99
|
+
<%= form.submit %>
|
|
100
|
+
<% end %>
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Typing `{{` opens the prompt shown above; there's also a toolbar dropdown for
|
|
104
|
+
picking from the same list. Inserted variables appear as chips in the editor:
|
|
105
|
+
|
|
106
|
+
<p align="center">
|
|
107
|
+
<img src="docs/images/editor-chips.png" alt="Variable chips inline in the Lexxy editor" width="606">
|
|
108
|
+
</p>
|
|
109
|
+
|
|
110
|
+
On the **display page** (where the saved content is shown to readers), resolve
|
|
111
|
+
the stored rich text. This is what swaps each variable chip for its value:
|
|
112
|
+
|
|
113
|
+
```erb
|
|
114
|
+
<%= render_lexxy_content(@record.body) %>
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Each chip resolves to its value, so the reader sees finished text:
|
|
118
|
+
|
|
119
|
+
<p align="center">
|
|
120
|
+
<img src="docs/images/rendered-output.png" alt="The saved message with every variable chip resolved to its value" width="606">
|
|
121
|
+
</p>
|
|
122
|
+
|
|
123
|
+
`@record` and `:body` are placeholders — use whatever model and Action Text
|
|
124
|
+
attribute hold your content.
|
|
125
|
+
|
|
126
|
+
## Full configuration
|
|
127
|
+
|
|
128
|
+
`context` is yours to define. The gem passes it untouched to your catalog,
|
|
129
|
+
assigns, and resolve callables, so put whatever they need in it. That might be a
|
|
130
|
+
tenant, `nil`, or any object.
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
LexxyVariables.configure do |c|
|
|
134
|
+
# The menu users pick from. A list, a zero-arg lambda, or a ->(context) lambda.
|
|
135
|
+
c.catalog = ->(context) { context.variables + BuiltinVariable.all }
|
|
136
|
+
|
|
137
|
+
# The lookup. Gets only the keys used in the content being rendered and returns
|
|
138
|
+
# a { key => value } hash. ->(used_keys) also works if you don't need context.
|
|
139
|
+
c.assigns = ->(context, used_keys) { MyResolver.assigns(context, used_keys) }
|
|
140
|
+
|
|
141
|
+
# Opt into Liquid for dotted access / drops / filters.
|
|
142
|
+
c.renderer = LexxyVariables::Renderers::Liquid.new
|
|
143
|
+
|
|
144
|
+
# How the catalog is ordered in the prompt and dropdown. Defaults to :name
|
|
145
|
+
# (case-insensitive alphabetical). Use :key to sort by key, false to keep the
|
|
146
|
+
# catalog's given order, or a lambda: a ->(item) sort key or a ->(a, b) comparator.
|
|
147
|
+
c.sort = :name
|
|
148
|
+
|
|
149
|
+
# Register an extra attachment type. :fragment splices rich HTML pre-sanitize
|
|
150
|
+
# (e.g. snippets) and its inner :value chips resolve in the same pass.
|
|
151
|
+
c.register_attachment(
|
|
152
|
+
content_type: "application/vnd.actiontext.snippet",
|
|
153
|
+
phase: :fragment,
|
|
154
|
+
label: "Snippet", # shown as a badge in the prompt when the list mixes types
|
|
155
|
+
resolve: ->(node, context) { MySnippets.content_for(node, context) }
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Multi-tenancy
|
|
161
|
+
|
|
162
|
+
Tenancy is optional. If your app is multi-tenant, pass the tenant through as
|
|
163
|
+
`context`. With [acts_as_tenant](https://github.com/ErwinM/acts_as_tenant) that
|
|
164
|
+
looks like:
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
LexxyVariables.configure do |c|
|
|
168
|
+
c.catalog = ->(tenant) { tenant.variables }
|
|
169
|
+
end
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Both view helpers take the same `context:`. Pass the tenant on the editor page:
|
|
173
|
+
|
|
174
|
+
```erb
|
|
175
|
+
<%= form.rich_text_area :body do %>
|
|
176
|
+
<%= lexxy_variables_prompt(context: ActsAsTenant.current_tenant) %>
|
|
177
|
+
<% end %>
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
and again on the display page:
|
|
181
|
+
|
|
182
|
+
```erb
|
|
183
|
+
<%= render_lexxy_content(@record.body, context: ActsAsTenant.current_tenant) %>
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Or skip `context` entirely and rely on acts_as_tenant scoping queries to the
|
|
187
|
+
current tenant for you:
|
|
188
|
+
|
|
189
|
+
```ruby
|
|
190
|
+
LexxyVariables.configure do |c|
|
|
191
|
+
c.catalog = -> { Variable.all } # already scoped to ActsAsTenant.current_tenant
|
|
192
|
+
c.assigns = ->(keys) { Variable.where(key: keys).pluck(:key, :value).to_h }
|
|
193
|
+
end
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Styling
|
|
197
|
+
|
|
198
|
+
The gem ships a default stylesheet so the editor UI works out of the box. Import
|
|
199
|
+
it and override the CSS custom properties (or the classes) to match your app.
|
|
200
|
+
|
|
201
|
+
```css
|
|
202
|
+
/* bundlers (esbuild, vite): */
|
|
203
|
+
@import "lexxy-variables/styles";
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
# importmap / asset-pipeline hosts: the engine adds the vendored CSS to the asset
|
|
208
|
+
# paths. Link or @import "lexxy_variables.css".
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Classes the gem emits: `.lexxy-variable` (token chip), `.lexxy-variable--block`
|
|
212
|
+
(chips that expand to a block, e.g. snippets), `.lexxy-variables-menu` /
|
|
213
|
+
`.lexxy-variables-menu__item` (the toolbar dropdown), and `.lexxy-variables-option`
|
|
214
|
+
/ `__header` / `__name` / `__type` / `__code` (option content, shared by the
|
|
215
|
+
`{{` prompt popup and the dropdown).
|
|
216
|
+
|
|
217
|
+
<p align="center">
|
|
218
|
+
<img src="docs/images/toolbar-dropdown.png" alt="The toolbar dropdown listing the variable catalog" width="640">
|
|
219
|
+
</p>
|
|
220
|
+
|
|
221
|
+
Override without touching the classes:
|
|
222
|
+
|
|
223
|
+
```css
|
|
224
|
+
:root {
|
|
225
|
+
--lexxy-variable-background: #fef3c7;
|
|
226
|
+
--lexxy-variable-color: #92400e;
|
|
227
|
+
--lexxy-variable-block-border: 1px dashed #f59e0b;
|
|
228
|
+
--lexxy-variables-menu-item-hover-background: #f4f4f5;
|
|
229
|
+
--lexxy-variables-option-code-color: #a1a1aa;
|
|
230
|
+
--lexxy-variables-option-type-background: #f4f4f5;
|
|
231
|
+
--lexxy-variables-option-type-color: #71717a;
|
|
232
|
+
--lexxy-variables-prompt-max-width: 24rem; /* widen the {{ prompt popup (Lexxy caps at 20ch) */
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Your prompt items should use the option classes so they appear the same in the
|
|
237
|
+
popup and the dropdown:
|
|
238
|
+
|
|
239
|
+
```erb
|
|
240
|
+
<template type="menu">
|
|
241
|
+
<span class="lexxy-variables-option">
|
|
242
|
+
<span class="lexxy-variables-option__name"><%= variable.name %></span>
|
|
243
|
+
<code class="lexxy-variables-option__code">{{ <%= variable.key %> }}</code>
|
|
244
|
+
</span>
|
|
245
|
+
</template>
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
## Security model
|
|
249
|
+
|
|
250
|
+
- Every render gets a fresh random nonce that guards the placeholder tokens, so an
|
|
251
|
+
author can't fake a substitution by typing the token pattern into the body.
|
|
252
|
+
- Chips are swapped for those nonce tokens before the HTML is sanitized, and the
|
|
253
|
+
real values go in afterward. A `:value` resolves to HTML-escaped text that can't
|
|
254
|
+
do anything, while a `:fragment` is spliced in before sanitizing so the sanitizer
|
|
255
|
+
still scrubs it.
|
|
256
|
+
- Only the Liquid renderer deals with template-engine braces (`{{ }}` and `{% %}`).
|
|
257
|
+
The default renderer runs no engine at all, so there's nothing there to inject into.
|
|
258
|
+
|
|
259
|
+
## Contributing
|
|
260
|
+
|
|
261
|
+
Bug reports and pull requests are welcome. To get set up:
|
|
262
|
+
|
|
263
|
+
```sh
|
|
264
|
+
bundle install
|
|
265
|
+
bundle exec rake test # run the test suite
|
|
266
|
+
bundle exec rubocop # lint
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
The editor extension in `src/` is compiled into `vendor/` (the copy importmap
|
|
270
|
+
apps load). If you change anything under `src/`, rebuild before committing or CI
|
|
271
|
+
will fail:
|
|
272
|
+
|
|
273
|
+
```sh
|
|
274
|
+
npm install
|
|
275
|
+
npm run build
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
CI runs the tests across Ruby 3.2–4.0, rubocop, and a check that `vendor/`
|
|
279
|
+
matches `src/`.
|
|
280
|
+
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<%# Feeds the editor extension. Built from LexxyVariables.config.catalog. A host
|
|
2
|
+
can override this file by creating app/views/lexxy_variables/_prompt.html.erb. %>
|
|
3
|
+
<% show_types = items.map { |i| LexxyVariables.item_content_type(i) }.uniq.size > 1 %>
|
|
4
|
+
<lexxy-prompt trigger="<%= trigger %>" name="variable" empty-results="<%= empty_results %>">
|
|
5
|
+
<% items.each do |item| %>
|
|
6
|
+
<% key = LexxyVariables.item_key(item) %>
|
|
7
|
+
<% name = LexxyVariables.item_name(item) %>
|
|
8
|
+
<% content_type = LexxyVariables.item_content_type(item) %>
|
|
9
|
+
<% type_label = show_types ? LexxyVariables.item_type_label(item) : nil %>
|
|
10
|
+
<lexxy-prompt-item search="<%= "#{name} #{key}" %>" sgid="<%= LexxyVariables.item_sgid(item) %>">
|
|
11
|
+
<template type="menu">
|
|
12
|
+
<span class="lexxy-variables-option">
|
|
13
|
+
<span class="lexxy-variables-option__header">
|
|
14
|
+
<span class="lexxy-variables-option__name"><%= name %></span>
|
|
15
|
+
<% if type_label.present? %>
|
|
16
|
+
<span class="lexxy-variables-option__type"><%= type_label %></span>
|
|
17
|
+
<% end %>
|
|
18
|
+
</span>
|
|
19
|
+
<code class="lexxy-variables-option__code">{{ <%= key %> }}</code>
|
|
20
|
+
</span>
|
|
21
|
+
</template>
|
|
22
|
+
<%= tag.template(type: "editor", "content-type": content_type) do %><%=
|
|
23
|
+
lexxy_variable_chip(name, key: key, block: LexxyVariables.block_content_type?(content_type))
|
|
24
|
+
%><% end %>
|
|
25
|
+
</lexxy-prompt-item>
|
|
26
|
+
<% end %>
|
|
27
|
+
</lexxy-prompt>
|
data/config/importmap.rb
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
# Bundler auto-requires a gem by its dashed name ("lexxy-variables"), but the
|
|
2
|
+
# implementation lives in lexxy_variables.rb (the LexxyVariables namespace). This
|
|
3
|
+
# shim bridges the two so `gem "lexxy-variables"` loads the gem with no `require:`.
|
|
4
|
+
require "lexxy_variables"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module LexxyVariables
|
|
2
|
+
# Convenience for hosts whose variable objects are not already Action Text
|
|
3
|
+
# attachables. Include it and set `attachable_content_type` so chips carry a
|
|
4
|
+
# real sgid and the pipeline can match them. Models that already include
|
|
5
|
+
# ActionText::Attachable do not need this.
|
|
6
|
+
module Attachable
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
include ActionText::Attachable
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def attachable_content_type
|
|
14
|
+
LexxyVariables::VARIABLE_CONTENT_TYPE
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module LexxyVariables
|
|
2
|
+
# Describes one kind of attachment chip the pipeline knows how to resolve.
|
|
3
|
+
# A chip is identified solely by its content-type, which the editor always
|
|
4
|
+
# writes and the model always persists.
|
|
5
|
+
#
|
|
6
|
+
# phase:
|
|
7
|
+
# :value the resolver returns a String key. The pipeline records it, emits
|
|
8
|
+
# a nonce-protected placeholder, and later substitutes the resolved
|
|
9
|
+
# HTML-escaped value. Safe by construction. Variables use this.
|
|
10
|
+
# :fragment the resolver returns rich content (ActionText content, RichText,
|
|
11
|
+
# or an HTML String) spliced into the document BEFORE sanitization,
|
|
12
|
+
# so the sanitizer cleans it and inner :value chips resolve in the
|
|
13
|
+
# same pass. Snippets use this. Bounded by max_fragment_depth.
|
|
14
|
+
#
|
|
15
|
+
# resolve: ->(node, context) { ... } returning a key (:value) or rich content
|
|
16
|
+
# (:fragment), or nil to drop the chip.
|
|
17
|
+
#
|
|
18
|
+
# label: optional short type name (e.g. "Variable", "Snippet"). The prompt shows
|
|
19
|
+
# it as a badge only when the list mixes more than one type, so users can tell
|
|
20
|
+
# them apart without noise when there is only one. nil shows no badge.
|
|
21
|
+
AttachmentType = Struct.new(:content_type, :phase, :resolve, :label, keyword_init: true) do
|
|
22
|
+
def value? = phase == :value
|
|
23
|
+
def fragment? = phase == :fragment
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
module LexxyVariables
|
|
2
|
+
# Host-supplied policy. The gem ships working defaults, so the smallest
|
|
3
|
+
# integration is just `config.catalog = [...]`. Everything else is optional.
|
|
4
|
+
#
|
|
5
|
+
# LexxyVariables.configure do |c|
|
|
6
|
+
# c.catalog = [ { key: "company", name: "Company", value: "Acme" } ]
|
|
7
|
+
# end
|
|
8
|
+
#
|
|
9
|
+
# catalog: the insertable items for the editor prompt. A list, a zero-arg
|
|
10
|
+
# callable, or a ->(context) callable. Items respond to #key, #name,
|
|
11
|
+
# optional #value (used by the default assigns), and #attachable_sgid.
|
|
12
|
+
# assigns: ->(context, used_keys) or ->(used_keys) returning a Hash of
|
|
13
|
+
# key => value for a render. Omit to read #value off catalog items.
|
|
14
|
+
# renderer: Renderers::Substitution (default, no Liquid) or Renderers::Liquid.
|
|
15
|
+
# max_fragment_depth: how deep :fragment chips (e.g. snippets) expand. The
|
|
16
|
+
# default of 1 resolves a snippet's inner variables and drops nested snippets.
|
|
17
|
+
# content_layout: the ActionText content layout wrapper for rendered output.
|
|
18
|
+
# sort: how the catalog is ordered in the prompt and dropdown. Defaults to
|
|
19
|
+
# :name (case-insensitive alphabetical). Use :key to sort by key, false to
|
|
20
|
+
# preserve the catalog's given order, or a callable: a ->(item) sort key, or
|
|
21
|
+
# a ->(a, b) comparator.
|
|
22
|
+
class Configuration
|
|
23
|
+
attr_accessor :assigns, :renderer, :content_layout, :max_fragment_depth, :sort
|
|
24
|
+
attr_reader :registry
|
|
25
|
+
attr_writer :catalog
|
|
26
|
+
|
|
27
|
+
def initialize
|
|
28
|
+
@registry = Registry.new
|
|
29
|
+
@renderer = Renderers::Substitution.new
|
|
30
|
+
@content_layout = "layouts/action_text/contents/content"
|
|
31
|
+
@max_fragment_depth = 1
|
|
32
|
+
@catalog = []
|
|
33
|
+
@sort = :name
|
|
34
|
+
register_default_variable_type
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Adds or overrides an attachment type. Variables are pre-registered. A host
|
|
38
|
+
# calls this to add types like snippets, or to override the variable resolver.
|
|
39
|
+
# `label` is an optional badge name shown in the prompt when types are mixed.
|
|
40
|
+
def register_attachment(content_type:, phase:, resolve:, label: nil)
|
|
41
|
+
registry.register(AttachmentType.new(content_type:, phase:, resolve:, label:))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def resolve_catalog(context)
|
|
45
|
+
sort_catalog(Array(call_with_context(@catalog, context)))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def resolve_assigns(context, keys)
|
|
49
|
+
return default_assigns(context, keys) unless @assigns
|
|
50
|
+
|
|
51
|
+
if @assigns.arity == 1
|
|
52
|
+
@assigns.call(keys)
|
|
53
|
+
else
|
|
54
|
+
@assigns.call(context, keys)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# The gem's built-in variable resolver. Prefers an sgid-backed attachable
|
|
61
|
+
# (models that respond to #key), and otherwise falls back to the key that
|
|
62
|
+
# lexxy_variable_chip embeds as data-lexxy-key in the chip content. The
|
|
63
|
+
# fallback is what makes a plain-hash catalog (no sgid) resolve. Hosts with
|
|
64
|
+
# extra policy (e.g. tenant scoping) override this by re-registering the type.
|
|
65
|
+
def register_default_variable_type
|
|
66
|
+
register_attachment(
|
|
67
|
+
content_type: LexxyVariables::VARIABLE_CONTENT_TYPE,
|
|
68
|
+
phase: :value,
|
|
69
|
+
resolve: ->(node, _context) {
|
|
70
|
+
attachable = LexxyVariables.attachable_from(node)
|
|
71
|
+
next attachable.key if attachable.respond_to?(:key)
|
|
72
|
+
|
|
73
|
+
LexxyVariables.chip_key(node)
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Default value resolution: look each used key up in the catalog and read
|
|
79
|
+
# #value. Used only when no custom `assigns` is configured.
|
|
80
|
+
def default_assigns(context, keys)
|
|
81
|
+
items = Array(resolve_catalog(context))
|
|
82
|
+
keys.each_with_object({}) do |key, hash|
|
|
83
|
+
item = items.find { |i| LexxyVariables.item_key(i) == key }
|
|
84
|
+
hash[key] = LexxyVariables.item_value(item) if item
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Orders the resolved catalog per the `sort` policy. Both the prompt DOM and
|
|
89
|
+
# the dropdown read from this, so ordering stays consistent across them.
|
|
90
|
+
def sort_catalog(items)
|
|
91
|
+
case @sort
|
|
92
|
+
when nil, false
|
|
93
|
+
items
|
|
94
|
+
when :name
|
|
95
|
+
items.sort_by { |item| LexxyVariables.item_name(item).to_s.downcase }
|
|
96
|
+
when :key
|
|
97
|
+
items.sort_by { |item| LexxyVariables.item_key(item).to_s.downcase }
|
|
98
|
+
else
|
|
99
|
+
if @sort.arity == 1
|
|
100
|
+
items.sort_by { |item| @sort.call(item) }
|
|
101
|
+
else
|
|
102
|
+
items.sort { |a, b| @sort.call(a, b) }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def call_with_context(value, context)
|
|
108
|
+
return value unless value.respond_to?(:call)
|
|
109
|
+
|
|
110
|
+
if value.arity == 0
|
|
111
|
+
value.call
|
|
112
|
+
else
|
|
113
|
+
value.call(context)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module LexxyVariables
|
|
2
|
+
# Wires the gem into a host Rails app: mixes in the helper and exposes the
|
|
3
|
+
# vendored JS so importmap apps can pin it with no JS tooling. Bundler apps
|
|
4
|
+
# (esbuild, etc.) instead resolve the npm package from node_modules and ignore
|
|
5
|
+
# the vendored copy.
|
|
6
|
+
class Engine < ::Rails::Engine
|
|
7
|
+
initializer "lexxy_variables.helper" do
|
|
8
|
+
ActiveSupport.on_load(:action_view) do
|
|
9
|
+
include LexxyVariables::Helper
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Make the vendored JS + CSS resolvable by the asset pipeline (Propshaft/Sprockets).
|
|
14
|
+
initializer "lexxy_variables.assets" do |app|
|
|
15
|
+
if app.config.respond_to?(:assets)
|
|
16
|
+
app.config.assets.paths << root.join("vendor/javascript").to_s
|
|
17
|
+
app.config.assets.paths << root.join("vendor/stylesheets").to_s
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Pin the vendored module for importmap-rails hosts. Without importmap-rails
|
|
22
|
+
# the config key does not exist and this initializer does nothing.
|
|
23
|
+
initializer "lexxy_variables.importmap", before: "importmap" do |app|
|
|
24
|
+
if app.config.respond_to?(:importmap)
|
|
25
|
+
app.config.importmap.paths << root.join("config/importmap.rb")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module LexxyVariables
|
|
2
|
+
# Mixed into Action View by the engine. `context` is opaque to the gem and is
|
|
3
|
+
# passed straight through to the host's catalog/assigns/resolve callables.
|
|
4
|
+
module Helper
|
|
5
|
+
def render_lexxy_content(rich_text, context: nil, locale: I18n.locale)
|
|
6
|
+
LexxyVariables::Pipeline.new(self).call(rich_text, context: context, locale: locale)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# Renders the <lexxy-prompt> the editor extension reads, built from the
|
|
10
|
+
# configured catalog. `context` is passed to the catalog callable.
|
|
11
|
+
def lexxy_variables_prompt(context: nil, trigger: "{{", empty_results: "No variables found")
|
|
12
|
+
items = Array(LexxyVariables.config.resolve_catalog(context))
|
|
13
|
+
render partial: "lexxy_variables/prompt",
|
|
14
|
+
locals: { items: items, trigger: trigger, empty_results: empty_results }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Default variable/attachment chip shown in the editor. `block:` renders the
|
|
18
|
+
# block style, for chips that expand to a rich fragment (e.g. snippets).
|
|
19
|
+
def lexxy_variable_chip(name, key:, block: false)
|
|
20
|
+
tag.span name,
|
|
21
|
+
class: [ "lexxy-variable", ("lexxy-variable--block" if block) ],
|
|
22
|
+
data: { lexxy_key: key }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
module LexxyVariables
|
|
2
|
+
# Turns stored rich text into rendered HTML with attachment chips resolved.
|
|
3
|
+
#
|
|
4
|
+
# Security invariants (do not weaken):
|
|
5
|
+
# 1. A random per-render nonce guards placeholder tokens, so an author cannot
|
|
6
|
+
# forge a substitution by typing the token pattern into the body.
|
|
7
|
+
# 2. Attachments are swapped for nonce tokens BEFORE sanitization and resolved
|
|
8
|
+
# values are injected AFTER. :value output is HTML-escaped and therefore
|
|
9
|
+
# inert. :fragment output is spliced pre-sanitize so the sanitizer cleans it.
|
|
10
|
+
# 3. Any template-engine escaping (Liquid braces) lives only in that renderer.
|
|
11
|
+
#
|
|
12
|
+
# Needs a view context (self, from the helper) for Action Text rendering.
|
|
13
|
+
# Build a fresh Pipeline per render, as the helper does. #call stores
|
|
14
|
+
# per-render state on the instance.
|
|
15
|
+
class Pipeline
|
|
16
|
+
def initialize(view, config = LexxyVariables.config)
|
|
17
|
+
@view = view
|
|
18
|
+
@config = config
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(rich_text, context: nil, locale: I18n.locale)
|
|
22
|
+
body = rich_text&.body
|
|
23
|
+
return "".html_safe if body.blank?
|
|
24
|
+
|
|
25
|
+
@context = context
|
|
26
|
+
@nonce = SecureRandom.hex(8)
|
|
27
|
+
@used_keys = []
|
|
28
|
+
|
|
29
|
+
I18n.with_locale(locale || I18n.locale) do
|
|
30
|
+
fragment = substitute(body.fragment, 0)
|
|
31
|
+
html = @view.render_action_text_content(
|
|
32
|
+
ActionText::Content.new(fragment.to_html, canonicalize: false)
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
assigns = @config.resolve_assigns(@context, @used_keys.uniq)
|
|
36
|
+
rendered = @config.renderer.render(html, nonce: @nonce, assigns: assigns)
|
|
37
|
+
|
|
38
|
+
@view.render(layout: @config.content_layout) { rendered.html_safe }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def substitute(fragment, depth)
|
|
45
|
+
fragment.replace(ActionText::Attachment.tag_name) do |node|
|
|
46
|
+
type = @config.registry.match(node)
|
|
47
|
+
next node unless type # images, files, and unknown attachments pass through
|
|
48
|
+
|
|
49
|
+
if type.value?
|
|
50
|
+
resolve_value(node, type)
|
|
51
|
+
elsif type.fragment?
|
|
52
|
+
resolve_fragment(node, type, depth)
|
|
53
|
+
else
|
|
54
|
+
node
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def resolve_value(node, type)
|
|
60
|
+
key = type.resolve.call(node, @context)
|
|
61
|
+
return empty_text(node) if key.nil?
|
|
62
|
+
|
|
63
|
+
@used_keys << key
|
|
64
|
+
Nokogiri::XML::Text.new(Placeholder.token(@nonce, key), node.document)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def resolve_fragment(node, type, depth)
|
|
68
|
+
return empty_text(node) if depth >= @config.max_fragment_depth
|
|
69
|
+
|
|
70
|
+
inner = coerce_fragment(type.resolve.call(node, @context))
|
|
71
|
+
return empty_text(node) if inner.nil?
|
|
72
|
+
|
|
73
|
+
substitute(inner, depth + 1)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Accepts whatever a :fragment resolver returns (ActionText content, rich
|
|
77
|
+
# text, a Nokogiri node, or an HTML string) and coerces it to the
|
|
78
|
+
# ActionText::Fragment that #substitute needs, or nil to drop the chip.
|
|
79
|
+
# The Nokogiri checks come before the respond_to? checks because Nokogiri
|
|
80
|
+
# nodes also respond to #fragment, with different behavior.
|
|
81
|
+
def coerce_fragment(value)
|
|
82
|
+
return nil if value.nil?
|
|
83
|
+
return nil if value.respond_to?(:blank?) && value.blank?
|
|
84
|
+
return value if value.is_a?(ActionText::Fragment)
|
|
85
|
+
return ActionText::Fragment.wrap(value) if value.is_a?(Nokogiri::XML::DocumentFragment)
|
|
86
|
+
return ActionText::Fragment.from_html(value.to_html) if value.is_a?(Nokogiri::XML::Node)
|
|
87
|
+
return value.fragment if value.respond_to?(:fragment) # ActionText::Content
|
|
88
|
+
return value.body&.fragment if value.respond_to?(:body) # ActionText::RichText
|
|
89
|
+
|
|
90
|
+
ActionText::Fragment.from_html(value.to_s)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def empty_text(node)
|
|
94
|
+
Nokogiri::XML::Text.new("", node.document)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module LexxyVariables
|
|
2
|
+
# The single definition of the placeholder token format. The pipeline writes
|
|
3
|
+
# tokens with #token and the renderers find them with #pattern, so the format
|
|
4
|
+
# only lives here.
|
|
5
|
+
module Placeholder
|
|
6
|
+
PREFIX = "@@lexxy-var-"
|
|
7
|
+
|
|
8
|
+
def self.token(nonce, key)
|
|
9
|
+
"#{PREFIX}#{nonce}:#{key}@@"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.pattern(nonce)
|
|
13
|
+
/#{Regexp.escape(PREFIX)}#{Regexp.escape(nonce)}:([a-z][a-z0-9_.]*)@@/
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module LexxyVariables
|
|
2
|
+
# Maps a chip's content-type to the attachment type that resolves it.
|
|
3
|
+
# Registering the same content-type again overrides the previous entry, so a
|
|
4
|
+
# host can replace a built-in type (e.g. variables) or add new ones (snippets).
|
|
5
|
+
# Nodes with no registered content-type (images, files, ...) match nothing and
|
|
6
|
+
# are left untouched by the pipeline.
|
|
7
|
+
class Registry
|
|
8
|
+
def initialize
|
|
9
|
+
@by_content_type = {}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def register(type)
|
|
13
|
+
@by_content_type[type.content_type] = type
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def match(node)
|
|
17
|
+
@by_content_type[node["content-type"]]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def types
|
|
21
|
+
@by_content_type.values
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module LexxyVariables
|
|
2
|
+
module Renderers
|
|
3
|
+
# Opt-in renderer that resolves placeholders through Liquid, enabling dotted
|
|
4
|
+
# access, drops (e.g. {{ company.name }}), and filters. Requires the `liquid`
|
|
5
|
+
# gem. It is loaded here, when a host instantiates this renderer, so apps on
|
|
6
|
+
# the default renderer never pull it in.
|
|
7
|
+
#
|
|
8
|
+
# Security: because Liquid does interpret {{ }} and {% %}, any such syntax the
|
|
9
|
+
# author typed as plain text is entity-escaped BEFORE our own placeholders are
|
|
10
|
+
# revealed, so users cannot forge Liquid. Only the nonce-protected placeholders
|
|
11
|
+
# this pipeline emitted become real Liquid tags.
|
|
12
|
+
#
|
|
13
|
+
# `assigns` is the Hash passed to Liquid: string values and/or Liquid::Drop
|
|
14
|
+
# instances. Drops must escape their own output, since Liquid output is emitted
|
|
15
|
+
# html_safe after sanitization.
|
|
16
|
+
class Liquid
|
|
17
|
+
def initialize(error_mode: :lax)
|
|
18
|
+
require "liquid"
|
|
19
|
+
@error_mode = error_mode
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def render(html, nonce:, assigns:)
|
|
23
|
+
html = html.gsub("{{", "{{").gsub("{%", "{%")
|
|
24
|
+
html = html.gsub(Placeholder.pattern(nonce)) { "{{ #{$1} }}" }
|
|
25
|
+
|
|
26
|
+
::Liquid::Template.parse(html, error_mode: @error_mode).render(assigns)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module LexxyVariables
|
|
2
|
+
module Renderers
|
|
3
|
+
# Default renderer. Replaces nonce placeholders with resolved values by plain
|
|
4
|
+
# string substitution. There is no template engine, so author-typed text can
|
|
5
|
+
# never be interpreted as code. Each value is HTML-escaped, so it is inert and
|
|
6
|
+
# cannot reintroduce markup the sanitizer already stripped.
|
|
7
|
+
#
|
|
8
|
+
# `assigns` is a Hash of key => value. Values are coerced to escaped strings.
|
|
9
|
+
class Substitution
|
|
10
|
+
def render(html, nonce:, assigns:)
|
|
11
|
+
html.gsub(Placeholder.pattern(nonce)) { ERB::Util.html_escape(assigns[$1].to_s) }
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
require "active_support"
|
|
2
|
+
require "erb"
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "nokogiri"
|
|
5
|
+
|
|
6
|
+
require "lexxy_variables/version"
|
|
7
|
+
require "lexxy_variables/placeholder"
|
|
8
|
+
require "lexxy_variables/attachment_type"
|
|
9
|
+
require "lexxy_variables/registry"
|
|
10
|
+
require "lexxy_variables/renderers/substitution"
|
|
11
|
+
require "lexxy_variables/renderers/liquid"
|
|
12
|
+
require "lexxy_variables/configuration"
|
|
13
|
+
require "lexxy_variables/pipeline"
|
|
14
|
+
require "lexxy_variables/attachable"
|
|
15
|
+
require "lexxy_variables/helper"
|
|
16
|
+
require "lexxy_variables/engine" if defined?(Rails::Engine)
|
|
17
|
+
|
|
18
|
+
# Insert and safely resolve variable/attachment tokens in Lexxy rich text.
|
|
19
|
+
#
|
|
20
|
+
# The gem owns the mechanism: the editor extension, the nonce-safe render
|
|
21
|
+
# pipeline, the attachment-type registry, and the renderers. The host app owns
|
|
22
|
+
# the policy: what variables exist (catalog), what a key resolves to (assigns),
|
|
23
|
+
# and any extra attachment types (register_attachment, e.g. snippets).
|
|
24
|
+
module LexxyVariables
|
|
25
|
+
# Default content-type carried by variable attachment chips. A host may
|
|
26
|
+
# register additional types (snippets, etc.) via Configuration#register_attachment.
|
|
27
|
+
VARIABLE_CONTENT_TYPE = "application/vnd.actiontext.variable"
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
def configure
|
|
31
|
+
yield config
|
|
32
|
+
config
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def config
|
|
36
|
+
@config ||= Configuration.new
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Primarily for tests / reloading.
|
|
40
|
+
def reset_config!
|
|
41
|
+
@config = Configuration.new
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Resolves an attachment node's sgid to its attachable, or nil if the sgid is
|
|
45
|
+
# missing or stale. Handy for host-supplied :value / :fragment resolvers.
|
|
46
|
+
def attachable_from(node)
|
|
47
|
+
ActionText::Attachable.from_attachable_sgid(node["sgid"])
|
|
48
|
+
rescue StandardError
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Reads the key that lexxy_variable_chip embeds as data-lexxy-key in the chip
|
|
53
|
+
# content, or nil if absent. This is how a plain-hash catalog (which has no
|
|
54
|
+
# sgid-backed attachable) maps a chip back to its catalog key at render.
|
|
55
|
+
def chip_key(node)
|
|
56
|
+
content = node["content"]
|
|
57
|
+
return nil if content.nil? || content.empty?
|
|
58
|
+
|
|
59
|
+
span = Nokogiri::HTML5.fragment(content).at_css("[data-lexxy-key]")
|
|
60
|
+
key = span && span["data-lexxy-key"]
|
|
61
|
+
key unless key.nil? || key.empty?
|
|
62
|
+
rescue StandardError
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Catalog items can be plain hashes or any objects that respond to the same
|
|
67
|
+
# fields. These accessors read the fields the prompt needs either way.
|
|
68
|
+
def item_name(item)
|
|
69
|
+
if item.is_a?(Hash)
|
|
70
|
+
item[:name]
|
|
71
|
+
else
|
|
72
|
+
item.name
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def item_key(item)
|
|
77
|
+
if item.is_a?(Hash)
|
|
78
|
+
item[:key]
|
|
79
|
+
else
|
|
80
|
+
item.key
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def item_value(item)
|
|
85
|
+
if item.is_a?(Hash)
|
|
86
|
+
item[:value]
|
|
87
|
+
else
|
|
88
|
+
item.value
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def item_sgid(item)
|
|
93
|
+
if item.is_a?(Hash)
|
|
94
|
+
item[:attachable_sgid]
|
|
95
|
+
else
|
|
96
|
+
item.attachable_sgid
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def item_content_type(item)
|
|
101
|
+
if item.is_a?(Hash)
|
|
102
|
+
item[:content_type]
|
|
103
|
+
elsif item.respond_to?(:attachable_content_type)
|
|
104
|
+
item.attachable_content_type
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# True when a content-type is registered as a :fragment type (e.g. snippets),
|
|
109
|
+
# so its chip should render in the block style.
|
|
110
|
+
def block_content_type?(content_type)
|
|
111
|
+
type = registered_type(content_type)
|
|
112
|
+
type ? type.fragment? : false
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The badge label for an item's type, or nil if none is registered.
|
|
116
|
+
def item_type_label(item)
|
|
117
|
+
registered_type(item_content_type(item))&.label
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def registered_type(content_type)
|
|
123
|
+
return nil if content_type.nil?
|
|
124
|
+
|
|
125
|
+
config.registry.match("content-type" => content_type)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import * as Lexxy from "@37signals/lexxy"
|
|
2
|
+
import { $getSelection, $isTextNode, $createTextNode } from "lexical"
|
|
3
|
+
|
|
4
|
+
const VARIABLE_CONTENT_TYPE = "application/vnd.actiontext.variable"
|
|
5
|
+
|
|
6
|
+
// Lexxy 0.9.22 anchors prompt replacement on lastIndexOf(trigger[0]), which
|
|
7
|
+
// lands on the second brace of a "{{" trigger and silently aborts the insert.
|
|
8
|
+
// Replace the search with one that scans back until the full string matches.
|
|
9
|
+
// Runs inside the caller's editor.update(), like the original.
|
|
10
|
+
// TODO: upstream to @37signals/lexxy and drop this patch.
|
|
11
|
+
let contentsPatched = false
|
|
12
|
+
|
|
13
|
+
function patchReplaceTextBackUntil(contents) {
|
|
14
|
+
if (contentsPatched) return
|
|
15
|
+
contentsPatched = true
|
|
16
|
+
|
|
17
|
+
const proto = Object.getPrototypeOf(contents)
|
|
18
|
+
proto.replaceTextBackUntil = function(stringToReplace, replacementNodes) {
|
|
19
|
+
replacementNodes = Array.isArray(replacementNodes) ? replacementNodes : [ replacementNodes ]
|
|
20
|
+
|
|
21
|
+
const selection = $getSelection()
|
|
22
|
+
if (!selection || !selection.isCollapsed()) return
|
|
23
|
+
const anchorNode = selection.anchor.getNode()
|
|
24
|
+
if (!$isTextNode(anchorNode)) return
|
|
25
|
+
|
|
26
|
+
const fullText = anchorNode.getTextContent()
|
|
27
|
+
const offset = selection.anchor.offset
|
|
28
|
+
|
|
29
|
+
let start = fullText.slice(0, offset).lastIndexOf(stringToReplace[0])
|
|
30
|
+
while (start !== -1 && !fullText.startsWith(stringToReplace, start)) {
|
|
31
|
+
start = fullText.slice(0, start).lastIndexOf(stringToReplace[0])
|
|
32
|
+
}
|
|
33
|
+
if (start === -1) return
|
|
34
|
+
|
|
35
|
+
const cloneFormatting = (text) => $createTextNode(text)
|
|
36
|
+
.setFormat(anchorNode.getFormat())
|
|
37
|
+
.setDetail(anchorNode.getDetail())
|
|
38
|
+
.setMode(anchorNode.getMode())
|
|
39
|
+
.setStyle(anchorNode.getStyle())
|
|
40
|
+
|
|
41
|
+
const textAfter = fullText.slice(start + stringToReplace.length)
|
|
42
|
+
const nodeBefore = cloneFormatting(fullText.slice(0, start))
|
|
43
|
+
const nodeAfter = cloneFormatting(textAfter || " ")
|
|
44
|
+
|
|
45
|
+
anchorNode.replace(nodeBefore)
|
|
46
|
+
let previous = nodeBefore
|
|
47
|
+
for (const node of replacementNodes) {
|
|
48
|
+
previous.insertAfter(node)
|
|
49
|
+
previous = node
|
|
50
|
+
}
|
|
51
|
+
previous.insertAfter(nodeAfter)
|
|
52
|
+
|
|
53
|
+
const cursorOffset = textAfter ? 0 : 1
|
|
54
|
+
nodeAfter.select(cursorOffset, cursorOffset)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const ICON = `
|
|
59
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
|
60
|
+
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
61
|
+
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
|
62
|
+
<path d="M7 4a2 2 0 0 0 -2 2v3a2 3 0 0 1 -2 3a2 3 0 0 1 2 3v3a2 2 0 0 0 2 2" />
|
|
63
|
+
<path d="M17 4a2 2 0 0 1 2 2v3a2 3 0 0 0 2 3a2 3 0 0 0 -2 3v3a2 2 0 0 1 -2 2" />
|
|
64
|
+
</svg>`
|
|
65
|
+
|
|
66
|
+
// Adds an "Insert variable" toolbar dropdown fed by the same server-rendered
|
|
67
|
+
// <lexxy-prompt name="variable"> items that power the typed "{{" trigger. The
|
|
68
|
+
// prompt items are produced by the host app, so this extension is content-agnostic.
|
|
69
|
+
export default class VariableExtension extends Lexxy.Extension {
|
|
70
|
+
get enabled() {
|
|
71
|
+
return this.editorElement.supportsRichText && this.promptItems.length > 0
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
get allowedElements() {
|
|
75
|
+
return [ { tag: "span", attributes: [ "data-lexxy-key", "class" ] } ]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
initializeToolbar(toolbar) {
|
|
79
|
+
patchReplaceTextBackUntil(this.editorElement.contents)
|
|
80
|
+
|
|
81
|
+
const dropdown = document.createElement("lexxy-toolbar-dropdown")
|
|
82
|
+
dropdown.className = "lexxy-editor__toolbar-dropdown"
|
|
83
|
+
dropdown.innerHTML = `
|
|
84
|
+
<button data-dropdown-trigger class="lexxy-editor__toolbar-button lexxy-editor__toolbar-button--chevron"
|
|
85
|
+
type="button" name="variable" title="Insert variable" aria-haspopup="menu" aria-expanded="false">${ICON}</button>
|
|
86
|
+
<div data-dropdown-panel role="menu" class="lexxy-variables-menu" aria-label="Variables" hidden></div>`
|
|
87
|
+
|
|
88
|
+
const panel = dropdown.querySelector("[data-dropdown-panel]")
|
|
89
|
+
for (const item of this.promptItems) {
|
|
90
|
+
panel.appendChild(this.#menuButtonFor(item, dropdown))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
toolbar.appendChild(dropdown)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
get promptItems() {
|
|
97
|
+
return Array.from(this.editorElement.querySelectorAll("lexxy-prompt[name='variable'] lexxy-prompt-item"))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
#menuButtonFor(item, dropdown) {
|
|
101
|
+
const button = document.createElement("button")
|
|
102
|
+
button.type = "button"
|
|
103
|
+
button.className = "lexxy-variables-menu__item"
|
|
104
|
+
button.setAttribute("role", "menuitem")
|
|
105
|
+
button.append(item.querySelector("template[type='menu']").content.cloneNode(true))
|
|
106
|
+
button.addEventListener("click", (event) => {
|
|
107
|
+
event.preventDefault()
|
|
108
|
+
dropdown.close()
|
|
109
|
+
this.#insert(item)
|
|
110
|
+
})
|
|
111
|
+
return button
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
#insert(item) {
|
|
115
|
+
const template = item.querySelector("template[type='editor']")
|
|
116
|
+
const attachment = document.createElement("action-text-attachment")
|
|
117
|
+
attachment.setAttribute("sgid", item.getAttribute("sgid"))
|
|
118
|
+
attachment.setAttribute("content-type", template.getAttribute("content-type") || VARIABLE_CONTENT_TYPE)
|
|
119
|
+
attachment.setAttribute("content", template.innerHTML.trim())
|
|
120
|
+
this.editorElement.contents.insertHtml(attachment.outerHTML)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Default styles for lexxy-variables. Import this for a working out-of-the-box
|
|
3
|
+
* look. Override the CSS custom properties (or the classes) to match your app.
|
|
4
|
+
*
|
|
5
|
+
* Classes this gem emits:
|
|
6
|
+
* .lexxy-variable variable token chip (in the editor)
|
|
7
|
+
* .lexxy-variable--block modifier for chips that expand to a block
|
|
8
|
+
* .lexxy-variables-menu the toolbar "insert variable" dropdown panel
|
|
9
|
+
* .lexxy-variables-menu__item a button within that dropdown
|
|
10
|
+
* .lexxy-variables-option option content, cloned into the {{ prompt
|
|
11
|
+
* .lexxy-variables-option__header popup AND the toolbar dropdown
|
|
12
|
+
* .lexxy-variables-option__name
|
|
13
|
+
* .lexxy-variables-option__type
|
|
14
|
+
* .lexxy-variables-option__code
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/* Variable token chip. In published pages chips are replaced by resolved values. */
|
|
18
|
+
.lexxy-variable {
|
|
19
|
+
display: inline-block;
|
|
20
|
+
padding: 0 0.4em;
|
|
21
|
+
border-radius: var(--lexxy-variable-radius, 0.375rem);
|
|
22
|
+
background: var(--lexxy-variable-background, #eef2ff);
|
|
23
|
+
color: var(--lexxy-variable-color, #3730a3);
|
|
24
|
+
font-size: 0.875em;
|
|
25
|
+
white-space: nowrap;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/* For chips that stand in for a rich block (e.g. snippets). Dashed edge hints
|
|
29
|
+
at "placeholder for a larger block". */
|
|
30
|
+
.lexxy-variable--block {
|
|
31
|
+
border: var(--lexxy-variable-block-border, 1px dashed #6366f1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* Toolbar "insert variable" dropdown. Deliberately a labelled list, not Lexxy's
|
|
35
|
+
icon-grid toolbar-dropdown-list. */
|
|
36
|
+
.lexxy-variables-menu {
|
|
37
|
+
display: flex;
|
|
38
|
+
flex-direction: column;
|
|
39
|
+
align-items: stretch;
|
|
40
|
+
min-width: var(--lexxy-variables-menu-min-width, 14rem);
|
|
41
|
+
max-width: var(--lexxy-variables-menu-max-width, min(20rem, 85vw));
|
|
42
|
+
max-height: var(--lexxy-variables-menu-max-height, 20rem);
|
|
43
|
+
overflow-y: auto;
|
|
44
|
+
background: var(--lexxy-variables-menu-background, #fff);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.lexxy-variables-menu__item {
|
|
48
|
+
display: flex;
|
|
49
|
+
padding: 0.5rem 0.75rem;
|
|
50
|
+
border: 0;
|
|
51
|
+
border-radius: 0.5rem;
|
|
52
|
+
background: none;
|
|
53
|
+
font: inherit;
|
|
54
|
+
text-align: left;
|
|
55
|
+
cursor: pointer;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.lexxy-variables-menu__item:hover {
|
|
59
|
+
background-color: var(--lexxy-variables-menu-item-hover-background, rgba(0, 0, 0, 0.06));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* Option content, cloned into both the {{ prompt popup (styled by Lexxy) and the
|
|
63
|
+
toolbar dropdown. Stacks the name over the code and ellipsizes long keys so a
|
|
64
|
+
narrow popup never wraps mid-token. */
|
|
65
|
+
/* Lexxy caps its prompt popup at 20ch, which wraps names and truncates keys.
|
|
66
|
+
Raise the cap (Lexxy sets it via :where(), so this plain class wins). */
|
|
67
|
+
.lexxy-prompt-menu {
|
|
68
|
+
max-inline-size: var(--lexxy-variables-prompt-max-width, min(24rem, 90vw));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.lexxy-variables-option {
|
|
72
|
+
display: flex;
|
|
73
|
+
flex-direction: column;
|
|
74
|
+
align-items: flex-start;
|
|
75
|
+
gap: 0.125rem;
|
|
76
|
+
min-width: 0;
|
|
77
|
+
max-width: 100%;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.lexxy-variables-option__header {
|
|
81
|
+
display: flex;
|
|
82
|
+
align-items: baseline;
|
|
83
|
+
justify-content: space-between;
|
|
84
|
+
gap: 0.5rem;
|
|
85
|
+
width: 100%;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.lexxy-variables-option__name {
|
|
89
|
+
min-width: 0;
|
|
90
|
+
font-size: 0.875rem;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* Type badge (e.g. "Snippet"), shown on the name row only when the list mixes
|
|
94
|
+
types. Sits to the right without adding a line. */
|
|
95
|
+
.lexxy-variables-option__type {
|
|
96
|
+
flex-shrink: 0;
|
|
97
|
+
padding: 0.0625rem 0.375rem;
|
|
98
|
+
border-radius: 9999px;
|
|
99
|
+
background: var(--lexxy-variables-option-type-background, rgba(0, 0, 0, 0.06));
|
|
100
|
+
color: var(--lexxy-variables-option-type-color, #6b7280);
|
|
101
|
+
font-size: 0.6875rem;
|
|
102
|
+
line-height: 1.4;
|
|
103
|
+
white-space: nowrap;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.lexxy-variables-option__code {
|
|
107
|
+
max-width: 100%;
|
|
108
|
+
padding: 0;
|
|
109
|
+
overflow: hidden;
|
|
110
|
+
background: transparent;
|
|
111
|
+
font-size: 0.75rem;
|
|
112
|
+
color: var(--lexxy-variables-option-code-color, #9ca3af);
|
|
113
|
+
white-space: nowrap;
|
|
114
|
+
text-overflow: ellipsis;
|
|
115
|
+
}
|
metadata
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: lexxy-variables
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.0.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Andrew Quinn
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: railties
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '8.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '8.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: actiontext
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '8.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '8.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: actionview
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '8.0'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '8.0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: activesupport
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '8.0'
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '8.0'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: nokogiri
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '1.15'
|
|
75
|
+
type: :runtime
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - ">="
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '1.15'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: liquid
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - ">="
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '5.0'
|
|
89
|
+
type: :development
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - ">="
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '5.0'
|
|
96
|
+
- !ruby/object:Gem::Dependency
|
|
97
|
+
name: minitest
|
|
98
|
+
requirement: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - ">="
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '5.0'
|
|
103
|
+
type: :development
|
|
104
|
+
prerelease: false
|
|
105
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
106
|
+
requirements:
|
|
107
|
+
- - ">="
|
|
108
|
+
- !ruby/object:Gem::Version
|
|
109
|
+
version: '5.0'
|
|
110
|
+
description: |
|
|
111
|
+
Insert and safely resolve variables in Lexxy rich text. The gem gives you an
|
|
112
|
+
editor button (and a `{{` prompt) for inserting variables, each stored as an
|
|
113
|
+
Action Text attachment chip rather than literal markup, and resolves each chip
|
|
114
|
+
to its value at render time. Register new chip types with `register_attachment`:
|
|
115
|
+
a :value chip resolves to an escaped string, a :fragment chip splices rich
|
|
116
|
+
content in before sanitization. Liquid is optional. The default renderer is
|
|
117
|
+
plain, injection-safe string substitution and pulls in no template engine.
|
|
118
|
+
executables: []
|
|
119
|
+
extensions: []
|
|
120
|
+
extra_rdoc_files: []
|
|
121
|
+
files:
|
|
122
|
+
- LICENSE
|
|
123
|
+
- README.md
|
|
124
|
+
- app/views/lexxy_variables/_prompt.html.erb
|
|
125
|
+
- config/importmap.rb
|
|
126
|
+
- lib/lexxy-variables.rb
|
|
127
|
+
- lib/lexxy_variables.rb
|
|
128
|
+
- lib/lexxy_variables/attachable.rb
|
|
129
|
+
- lib/lexxy_variables/attachment_type.rb
|
|
130
|
+
- lib/lexxy_variables/configuration.rb
|
|
131
|
+
- lib/lexxy_variables/engine.rb
|
|
132
|
+
- lib/lexxy_variables/helper.rb
|
|
133
|
+
- lib/lexxy_variables/pipeline.rb
|
|
134
|
+
- lib/lexxy_variables/placeholder.rb
|
|
135
|
+
- lib/lexxy_variables/registry.rb
|
|
136
|
+
- lib/lexxy_variables/renderers/liquid.rb
|
|
137
|
+
- lib/lexxy_variables/renderers/substitution.rb
|
|
138
|
+
- lib/lexxy_variables/version.rb
|
|
139
|
+
- vendor/javascript/lexxy_variables.js
|
|
140
|
+
- vendor/stylesheets/lexxy_variables.css
|
|
141
|
+
homepage: https://github.com/anquinn/lexxy-variables
|
|
142
|
+
licenses:
|
|
143
|
+
- MIT
|
|
144
|
+
metadata:
|
|
145
|
+
source_code_uri: https://github.com/anquinn/lexxy-variables
|
|
146
|
+
changelog_uri: https://github.com/anquinn/lexxy-variables/blob/main/CHANGELOG.md
|
|
147
|
+
bug_tracker_uri: https://github.com/anquinn/lexxy-variables/issues
|
|
148
|
+
rdoc_options: []
|
|
149
|
+
require_paths:
|
|
150
|
+
- lib
|
|
151
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
152
|
+
requirements:
|
|
153
|
+
- - ">="
|
|
154
|
+
- !ruby/object:Gem::Version
|
|
155
|
+
version: '3.2'
|
|
156
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
157
|
+
requirements:
|
|
158
|
+
- - ">="
|
|
159
|
+
- !ruby/object:Gem::Version
|
|
160
|
+
version: '0'
|
|
161
|
+
requirements: []
|
|
162
|
+
rubygems_version: 4.0.10
|
|
163
|
+
specification_version: 4
|
|
164
|
+
summary: Insert and safely resolve variables in Lexxy rich text, stored as Action
|
|
165
|
+
Text attachment chips.
|
|
166
|
+
test_files: []
|