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 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>
@@ -0,0 +1,3 @@
1
+ # Importmap pin for host apps using importmap-rails. Bundler-based hosts ignore
2
+ # this and resolve the npm package from node_modules instead.
3
+ pin "lexxy-variables", to: "lexxy_variables.js", preload: true
@@ -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("{{", "&#123;&#123;").gsub("{%", "&#123;%")
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,3 @@
1
+ module LexxyVariables
2
+ VERSION = "0.0.1"
3
+ 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: []