actionview-vue_tag_helper 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 12f7b06e1478214f4dcd66a37e4e3b4aaa30dfe320968de3bfecd5197ca0ee5d
4
+ data.tar.gz: 7cd2b775fe4cd076cc6d7bda65368084fc8d934d9fa486dcc6cda379ba28c424
5
+ SHA512:
6
+ metadata.gz: c3c40fedfabc6d3b1fb356b7b49383b56194acd2c7dd85558387b95db9da5ca70b7a24a03735bbb2d3f7fa4a9610cc91f3716f179ac18838b995c6df66d9a1b2
7
+ data.tar.gz: 586c97c37a9bf471442af409c943e7784a2381fc2b397feca23f7cd320e1e92fe7d7e4297c76adbb81ac6315f689720b21b3fa4b9c76a0905f29d84fe71a9799
data/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ### Added
11
+
12
+ Initial release.
13
+
14
+ [unreleased]: https://github.com/dmke/actionview-vue_tag_helper/compare/v0.1.0...HEAD
15
+ [0.1.0]: https://github.com/dmke/actionview-vue_tag_helper/releases/tag/v0.1.0
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dominik Menke
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,151 @@
1
+ # actionview-vue_tag_helper
2
+
3
+ An ActionView helper for embedding Vue components in server-rendered HTML,
4
+ targeting the [Islands architecture](https://jasonformat.com/islands-architecture/):
5
+ multiple independent Vue instances mounted on otherwise static pages,
6
+ without a full SPA or SSR pipeline.
7
+
8
+ ## Overview
9
+
10
+ In the Islands model, Rails delivers ordinary HTML and Vue is mounted on
11
+ individual "islands" — interactive widgets that need typed props, reactive
12
+ state, or component-library primitives. The built-in `tag` helper works
13
+ fine for plain HTML but is a poor fit here: it always uses double-quote
14
+ delimiters and entity-encodes `"` inside attribute values, turning a
15
+ simple JSON prop into noise, and it has no concept of Vue's typed props
16
+ or `v-bind` shorthand.
17
+
18
+ `actionview-vue_tag_helper` provides a `vue` helper with the same
19
+ builder interface but tuned for Vue component output:
20
+
21
+ ```erb
22
+ <%# tag helper — double-quotes force &quot; encoding inside JSON %>
23
+ <%= tag.my_component data: { config: {key: "val"}.to_json } %>
24
+ <%# => <my-component data-config="{&quot;key&quot;:&quot;val&quot;}"></my-component> %>
25
+
26
+ <%# vue helper — switches to single quotes when the value contains " %>
27
+ <%= vue.my_component data: { config: {key: "val"} } %>
28
+ <%# => <my-component data-config='{"key":"val"}'></my-component> %>
29
+ ```
30
+
31
+ ### Tag names
32
+
33
+ Method names are dasherized: `vue.my_feed_item` and `vue.MyFeedItem`
34
+ both produce `<my-feed-item>`. The resulting name must contain at least
35
+ one hyphen (the HTML Living Standard requirement for custom element
36
+ names). Anything that doesn't meet this — a plain word like `vue.div`,
37
+ for example — raises `ArgumentError` immediately.
38
+
39
+ Every tag is emitted with an explicit closing tag; Vue component tags are
40
+ never self-closing.
41
+
42
+ ### Typed attributes / v-bind shorthand
43
+
44
+ Vue components use typed props. Passing `count="42"` (a string) when the
45
+ component declares `count: Number` triggers a Vue runtime warning. The
46
+ `vue` helper avoids this by inspecting the Ruby value:
47
+
48
+ | Ruby value | Emitted attribute |
49
+ |:-----------|:------------------|
50
+ | `String`, `Symbol` | `label="hello"` — plain attribute |
51
+ | `true` | `disabled` — valueless (Vue treats presence as truthy) |
52
+ | `Integer`, `Float`, `false`, `Array`, `Hash`, … | `:count="42"` — v-bind shorthand, value serialised as JSON |
53
+
54
+ ```erb
55
+ <%= vue.b_btn(disabled: true) %>
56
+ <%# => <b-btn disabled></b-btn> %>
57
+
58
+ <%= vue.x_counter(count: 42, ratio: 1.5) %>
59
+ <%# => <x-counter :count="42" :ratio="1.5"></x-counter> %>
60
+
61
+ <%= vue.my_component(items: %w[a b]) %>
62
+ <%# => <my-component :items='["a","b"]'></my-component> %>
63
+ ```
64
+
65
+ The `class:` key is exempt: an `Array` or `Hash` value is always
66
+ flattened into a space-separated CSS token list, never v-bound.
67
+
68
+ ### `data:` and `aria:` hashes
69
+
70
+ A Hash under `data:` or `aria:` is expanded into individual prefixed
71
+ attributes. Complex values are serialised to JSON; `aria:` values are
72
+ always plain strings (WAI-ARIA is string-based).
73
+
74
+ ```erb
75
+ <%= vue.my_component(data: { user: { id: 1, name: "Alice" } }) %>
76
+ <%# => <my-component data-user='{"id":1,"name":"Alice"}'></my-component> %>
77
+ ```
78
+
79
+ ## Installation
80
+
81
+ Add to your `Gemfile`:
82
+
83
+ ```ruby
84
+ gem "actionview-vue_tag_helper"
85
+ ```
86
+
87
+ The helper is available in all views as soon as the gem is loaded. No
88
+ `include` or initialiser is required — it hooks into ActionView via
89
+ `ActiveSupport.on_load(:action_view)`.
90
+
91
+ ## Contributing
92
+
93
+ Pull requests are welcome. Please always add a test with your changes.
94
+
95
+ ### Prerequisites
96
+
97
+ - [rbenv](https://github.com/rbenv/rbenv) with the
98
+ [rbenv-gemset](https://github.com/jf/rbenv-gemset) plugin
99
+ - Ruby 4.0 (`rbenv install 4.0`)
100
+
101
+ The project uses local gemsets stored under `.gems/` (gitignored).
102
+ `.ruby-version` and `.ruby-gemset` are already checked in, so rbenv
103
+ picks up the right Ruby and gemset automatically when you `cd` into the
104
+ project.
105
+
106
+ ### Setup
107
+
108
+ ```sh
109
+ git clone https://github.com/dmke/actionview-vue_tag_helper
110
+ cd actionview-vue_tag_helper
111
+ bundle install # installs into .gems/4.0/...
112
+ ```
113
+
114
+ ### Running the tests
115
+
116
+ Against the default gemfile (Rails 8.1):
117
+
118
+ ```sh
119
+ bundle exec rspec
120
+ ```
121
+
122
+ Against all supported Rails versions:
123
+
124
+ ```sh
125
+ bin/test
126
+ ```
127
+
128
+ Each gemfile gets its own isolated gemset under `.gems/` (e.g.
129
+ `.gems/rails_8_0`, `.gems/rails_8_1`, `.gems/rails_main`).
130
+
131
+ ### Updating dependencies
132
+
133
+ ```sh
134
+ bin/bundle-all update
135
+ ```
136
+
137
+ ### Cleaning stale gems
138
+
139
+ ```sh
140
+ bin/bundle-all clean --force
141
+ ```
142
+
143
+ ### Linting
144
+
145
+ ```sh
146
+ bundle exec rubocop
147
+ ```
148
+
149
+ ## License
150
+
151
+ MIT - see [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module ActionView
6
+ module VueTagHelper
7
+ # Integrates the +vue+ view helper into Rails applications.
8
+ #
9
+ # When Rails is present this Railtie is required automatically and the
10
+ # helper is included into +ActionView::Base+ via the +:action_view+ load
11
+ # hook, which is the correct integration point for ActionView extensions.
12
+ class Railtie < Rails::Railtie
13
+ initializer "vue_tag_helper.action_view" do
14
+ ActiveSupport.on_load(:action_view) do
15
+ include ActionView::Helpers::VueTagHelper
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionView
4
+ module VueTagHelper
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,357 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_view"
4
+
5
+ module ActionView
6
+ module Helpers
7
+ module TagHelper
8
+ # VueBuilder generates Vue component markup from a proxy-style builder
9
+ # interface identical to the built-in +tag+ helper:
10
+ #
11
+ # @example
12
+ # vue.my_component(label: "Hello")
13
+ # # => <my-component label="Hello"></my-component>
14
+ #
15
+ # It is intentionally *not* a subclass of TagBuilder. The two helpers
16
+ # have different semantics and will diverge further as Vue-specific
17
+ # features are added.
18
+ #
19
+ # ## Tag names
20
+ #
21
+ # Method names are dasherized before use as the HTML tag name
22
+ # (+my_feed_item+ → +my-feed-item+). The resulting name must be a valid
23
+ # kebab-cased custom-element name: all-lowercase, letters/digits only,
24
+ # and at least one hyphen separating two non-empty segments. Anything
25
+ # else — PascalCase, a plain lowercase word, underscores — raises
26
+ # +ArgumentError+ immediately, before attributes or content are evaluated.
27
+ #
28
+ # @example
29
+ # vue.my_component # => <my-component></my-component> ✓
30
+ # vue.MyComponent # => <my-component></my-component> ✓ (PascalCase normalised)
31
+ # vue.div # => ArgumentError ("div") ✗
32
+ #
33
+ # This mirrors the HTML Living Standard requirement that custom element
34
+ # names contain at least one hyphen, which also guarantees they can never
35
+ # collide with current or future built-in HTML elements.
36
+ #
37
+ # ## Closing tags
38
+ #
39
+ # Every tag is emitted with an explicit closing tag. Vue component tags
40
+ # are never self-closing.
41
+ #
42
+ # ## Attribute quoting
43
+ #
44
+ # Standard TagBuilder always uses double-quote delimiters and escapes any
45
+ # literal +"+ inside a value as +&quot;+. That is safe but verbose — a
46
+ # JSON blob like +{"key":"value"}+ becomes
47
+ # +data="{&quot;key&quot;:&quot;value&quot;}"+.
48
+ #
49
+ # VueBuilder switches to single-quote delimiters whenever the stringified
50
+ # value contains a double-quote, so the same blob is emitted as
51
+ # +data='{"key":"value"}'+. The delimiter decision is independent
52
+ # of the +escape:+ flag.
53
+ #
54
+ # Escaping rules when single-quote delimiters are chosen:
55
+ #
56
+ # & → &amp;
57
+ # < → &lt;
58
+ # > → &gt;
59
+ # ' → &#39; (protects the delimiter)
60
+ # " → (unchanged — the whole point)
61
+ #
62
+ # Values already marked +html_safe?+ are passed through as-is except that
63
+ # a literal +\'+ is escaped when single-quote delimiters are in use.
64
+ #
65
+ # ## Typed attributes / v-bind shorthand
66
+ #
67
+ # Vue components use typed props (+defineProps<{ count: number }>()+).
68
+ # Passing a plain HTML string like +count="42"+ triggers a Vue runtime
69
+ # warning because the prop expects a Number, not a String.
70
+ #
71
+ # VueBuilder avoids this by inspecting the Ruby value type:
72
+ #
73
+ # - +String+ / +Symbol+ — emitted as a plain attribute (no colon).
74
+ # - +true+ — emitted as a valueless attribute (+disabled+). Vue
75
+ # interprets attribute presence as a truthy boolean.
76
+ # - everything else (+Integer+, +Float+, +BigDecimal+, +false+,
77
+ # +Array+, +Hash+, …) — the attribute name is prefixed with +:+
78
+ # (the +v-bind+ shorthand) and the value is serialised with
79
+ # +#to_json+. The resulting JSON string is then subject to the
80
+ # normal single/double-quote quoting rules.
81
+ #
82
+ # @example
83
+ # vue.my_component(count: 42)
84
+ # # => <my-component :count="42"></my-component>
85
+ #
86
+ # vue.my_component(items: ["a", "b"])
87
+ # # => <my-component :items='["a","b"]'></my-component>
88
+ #
89
+ # vue.b_btn(disabled: true)
90
+ # # => <b-btn disabled></b-btn>
91
+ #
92
+ # vue.b_btn(disabled: false)
93
+ # # => <b-btn :disabled="false"></b-btn>
94
+ #
95
+ # The +class:+ key is exempt from the v-bind rule: an Array or Hash
96
+ # value is always flattened into a space-separated CSS token list.
97
+ #
98
+ # ## data: and aria: hashes
99
+ #
100
+ # A Hash passed under the +data:+ or +aria:+ key is expanded into
101
+ # individual prefixed attributes. Values are serialised as follows:
102
+ #
103
+ # - +String+, +Symbol+ — passed through unchanged.
104
+ # - +BigDecimal+ — converted with +to_s("F")+ (fixed-point notation,
105
+ # independent of any ActiveSupport monkey-patches).
106
+ # - everything else — serialised with +#to_json+.
107
+ #
108
+ # @example
109
+ # vue.my_component(data: {items: ["a", "b"]})
110
+ # # => <my-component data-items='["a","b"]'></my-component>
111
+ #
112
+ class VueBuilder
113
+ # Raised when a method name cannot be normalised to a valid kebab-cased
114
+ # Vue component tag name (i.e. one containing at least one hyphen).
115
+ #
116
+ # Inherits from +ArgumentError+ so existing rescues on +ArgumentError+
117
+ # continue to work.
118
+ #
119
+ # @param original [String] the method name as called (pre-normalisation)
120
+ # @param normalised [String] the name after +.underscore.dasherize+
121
+ class InvalidTagNameError < ArgumentError
122
+ def initialize(original, normalised = original)
123
+ detail = if original == normalised
124
+ original.inspect
125
+ else
126
+ "#{original.inspect} (normalised to #{normalised.inspect})"
127
+ end
128
+
129
+ super(<<~MESSAGE.strip)
130
+ Vue component tag names must be kebab-cased with at least one hyphen
131
+ (e.g. "my-component"), got: #{detail}
132
+ MESSAGE
133
+ end
134
+ end
135
+
136
+ # Valid Vue/custom-element tag name after dasherization:
137
+ # lowercase segments of letters and digits joined by hyphens, with at
138
+ # least one hyphen present.
139
+ KEBAB_TAG_RE = /\A[a-z][a-z0-9]*(-[a-z0-9]+)+\z/
140
+ private_constant :KEBAB_TAG_RE
141
+
142
+ # Characters that need escaping inside single-quoted HTML attributes.
143
+ # Note the deliberate absence of +" +: it is safe unescaped when the
144
+ # delimiter is a single quote.
145
+ SINGLE_QUOTE_ATTR_ESCAPE = {
146
+ "&" => "&amp;",
147
+ "<" => "&lt;",
148
+ ">" => "&gt;",
149
+ "'" => "&#39;",
150
+ }.freeze
151
+ private_constant :SINGLE_QUOTE_ATTR_ESCAPE
152
+
153
+ def initialize(view_context)
154
+ @view_context = view_context
155
+ end
156
+
157
+ private
158
+
159
+ def respond_to_missing?(*, **)
160
+ true
161
+ end
162
+
163
+ def method_missing(called, *args, escape: true, **options, &)
164
+ original = called.name
165
+ name = original.underscore.dasherize
166
+ raise InvalidTagNameError.new(original, name) unless KEBAB_TAG_RE.match?(name)
167
+
168
+ content = build_inline_content(args, escape, &)
169
+ "<#{name}#{build_tag_options(options, escape)}>#{content}</#{name}>".html_safe # rubocop:disable Rails/OutputSafety
170
+ end
171
+
172
+ def build_inline_content(args, escape, &block)
173
+ return @view_context.capture(self, &block) if block
174
+
175
+ args.first&.then { |i| escape ? ERB::Util.unwrapped_html_escape(i) : i.to_s }
176
+ end
177
+
178
+ # Serialises the options hash to an HTML attribute string.
179
+ #
180
+ # +data:+ and +aria:+ sub-hashes are expanded into prefixed attributes.
181
+ # All other key/value pairs are forwarded to +typed_tag_option+.
182
+ # +nil+ values are always omitted.
183
+ #
184
+ # @param options [Hash]
185
+ # @param escape [Boolean]
186
+ # @return [String, nil]
187
+ def build_tag_options(options, escape)
188
+ return if options.blank?
189
+
190
+ output = +""
191
+ options.each_pair { |k, v| append_one_option(output, k, v, escape) }
192
+ output unless output.empty?
193
+ end
194
+
195
+ def append_one_option(output, key, value, escape)
196
+ return if key.blank?
197
+
198
+ case key.to_s
199
+ when "data"
200
+ value.is_a?(Hash) && append_data_options(output, value, escape)
201
+ when "aria"
202
+ value.is_a?(Hash) && append_aria_options(output, value, escape)
203
+ else
204
+ output << " " << typed_tag_option(key, value, escape) unless value.nil?
205
+ end
206
+ end
207
+
208
+ def append_data_options(output, hash, escape)
209
+ hash.each_pair do |k, v|
210
+ output << " " << prefix_tag_option("data", k, v, escape) unless k.blank? || v.nil?
211
+ end
212
+ end
213
+
214
+ def append_aria_options(output, hash, escape)
215
+ hash.each_pair do |k, v|
216
+ next if k.blank? || v.nil?
217
+
218
+ v = resolve_aria_value(v)
219
+ output << " " << prefix_tag_option("aria", k, v, escape) unless v.nil?
220
+ end
221
+ end
222
+
223
+ def resolve_aria_value(value)
224
+ case value
225
+ when Array, Hash
226
+ tokens = TagHelper.build_tag_values(value)
227
+ tokens.any? ? @view_context.safe_join(tokens, " ") : nil
228
+ else
229
+ value.to_s
230
+ end
231
+ end
232
+
233
+ # Applies Vue-aware typing rules before delegating to +tag_option+.
234
+ #
235
+ # - +true+ — emits a valueless attribute (e.g. +disabled+).
236
+ # - +String+, +Symbol+ — passes through to +tag_option+ unchanged; no colon prefix.
237
+ # - +Array+, +Hash+ under +class:+ — passes through for CSS token-list expansion.
238
+ # - everything else — prepends +:+ to the key (v-bind shorthand) and serialises
239
+ # the value with +#to_json+, then passes to +tag_option+.
240
+ def typed_tag_option(key, value, escape)
241
+ case value
242
+ when true
243
+ escape ? ERB::Util.xml_name_escape(key.to_s) : key.to_s
244
+ when String, Symbol
245
+ tag_option(key, value, escape)
246
+ when Array, Hash
247
+ key.to_s == "class" ? tag_option(key, value, escape) : tag_option(":#{key}", value.to_json, escape)
248
+ else
249
+ tag_option(":#{key}", value.to_json, escape)
250
+ end
251
+ end
252
+
253
+ # Serialises a +data-*+ or +aria-*+ attribute.
254
+ #
255
+ # - +String+, +Symbol+ — passed through unchanged.
256
+ # - +BigDecimal+ — converted with +to_s("F")+ (fixed-point notation, no
257
+ # ActiveSupport dependency). Using +to_json+ would produce a
258
+ # JSON-encoded string literal (+'"3.14"'+) rather than a plain decimal
259
+ # string (++"3.14"++); using bare +to_s+ without the format argument
260
+ # produces scientific notation (++"0.314e1"++) in plain Ruby.
261
+ # - everything else — serialised with +#to_json+.
262
+ #
263
+ # @param prefix [String] +"data"+ or +"aria"+
264
+ # @param key [#to_s]
265
+ # @param value [Object]
266
+ # @param escape [Boolean]
267
+ # @return [String]
268
+ def prefix_tag_option(prefix, key, value, escape)
269
+ attr_key = "#{prefix}-#{key.to_s.dasherize}"
270
+ value = case value
271
+ when String, Symbol
272
+ value
273
+ when BigDecimal
274
+ value.to_s("F")
275
+ else
276
+ value.to_json
277
+ end
278
+ tag_option(attr_key, value, escape)
279
+ end
280
+
281
+ # Serialises a single attribute key/value pair.
282
+ #
283
+ # Arrays and Hashes under a +class+ key are flattened to a
284
+ # space-separated token list with double-quote delimiters. All other
285
+ # values go through +build_quoted_attr+ for the single/double-quote
286
+ # delimiter decision.
287
+ #
288
+ # @param key [String]
289
+ # @param value [Object]
290
+ # @param escape [Boolean]
291
+ # @return [String]
292
+ def tag_option(key, value, escape)
293
+ key = ERB::Util.xml_name_escape(key.to_s) if escape
294
+ case value
295
+ when Array, Hash
296
+ token_list_attr(key, value, escape)
297
+ when Regexp
298
+ build_quoted_attr(key, value.source, escape)
299
+ else
300
+ build_quoted_attr(key, value.to_s, escape)
301
+ end
302
+ end
303
+
304
+ def token_list_attr(key, value, escape)
305
+ value = TagHelper.build_tag_values(value) if key == "class"
306
+ value = escape ? @view_context.safe_join(value, " ") : value.join(" ")
307
+ value = value.gsub('"', "&quot;") if value.include?('"')
308
+ %(#{key}="#{value}")
309
+ end
310
+
311
+ # Chooses single-quote or double-quote delimiters based solely on
312
+ # whether +raw+ contains a double-quote character, then escapes and
313
+ # wraps the value.
314
+ #
315
+ # The delimiter choice is independent of +escape+: it is a structural
316
+ # concern (preventing attribute breakage), not a safety one.
317
+ #
318
+ # +raw+ must already be a String. If it is +html_safe?+, the
319
+ # escaping step is skipped on the double-quote path (mirroring
320
+ # +ERB::Util.unwrapped_html_escape+) and reduced to +\'+ escaping only
321
+ # on the single-quote path.
322
+ #
323
+ # @param key [String]
324
+ # @param raw [String]
325
+ # @param escape [Boolean]
326
+ # @return [String]
327
+ def build_quoted_attr(key, raw, escape)
328
+ if raw.include?('"')
329
+ escaped = escape ? escape_for_single_quoted_attr(raw) : raw
330
+ %(#{key}='#{escaped}')
331
+ else
332
+ value = escape ? ERB::Util.unwrapped_html_escape(raw) : raw
333
+ value = value.gsub('"', "&quot;") if value.include?('"')
334
+ %(#{key}="#{value}")
335
+ end
336
+ end
337
+
338
+ # Escapes +str+ for embedding in a single-quoted HTML attribute.
339
+ #
340
+ # For +html_safe?+ strings, only the single-quote delimiter character
341
+ # is escaped — the caller is responsible for the rest of the content.
342
+ # For plain strings, +&+, +<+, +>+, and +'+ are all escaped; +"+ is
343
+ # intentionally left as-is.
344
+ #
345
+ # @param str [String]
346
+ # @return [String]
347
+ def escape_for_single_quoted_attr(str)
348
+ if str.html_safe?
349
+ str.include?("'") ? str.gsub("'", "&#39;") : str
350
+ else
351
+ str.gsub(/[&<>']/, SINGLE_QUOTE_ATTR_ESCAPE)
352
+ end
353
+ end
354
+ end
355
+ end
356
+ end
357
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_view"
4
+ require "bigdecimal"
5
+ require "active_support/core_ext/object/json"
6
+ require_relative "vue_tag_helper/version"
7
+ require_relative "vue_tag_helper/vue_builder"
8
+
9
+ module ActionView
10
+ module Helpers
11
+ # Provides the +vue+ view helper, a sibling of the built-in +tag+ helper
12
+ # tuned for rendering Vue component markup.
13
+ #
14
+ # See ActionView::Helpers::TagHelper::VueBuilder for full documentation on
15
+ # how attribute quoting differs from the standard TagBuilder.
16
+ #
17
+ # @example
18
+ # vue.MyComponent(label: "Hello", data: {items: [{id: 1}]})
19
+ # # => <my-component label="Hello" data-items='[{"id":1}]'></my-component>
20
+ #
21
+ module VueTagHelper
22
+ # Returns the VueBuilder proxy for this view context.
23
+ #
24
+ # Every call within the same render cycle returns the same instance,
25
+ # mirroring how +tag+ works.
26
+ #
27
+ # @return [ActionView::Helpers::TagHelper::VueBuilder]
28
+ def vue
29
+ @vue ||= TagHelper::VueBuilder.new(self)
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ if defined?(Rails::Railtie)
36
+ require_relative "vue_tag_helper/railtie"
37
+ else
38
+ ActiveSupport.on_load(:action_view) { include ActionView::Helpers::VueTagHelper }
39
+ end
metadata ADDED
@@ -0,0 +1,70 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: actionview-vue_tag_helper
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Dominik Menke
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: actionview
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
+ description: |
27
+ Extends ActionView with a `vue` helper for the Islands architecture: Vue
28
+ components mounted on otherwise static, server-rendered pages. Unlike the
29
+ built-in `tag` helper, it emits v-bind shorthand for typed props, switches
30
+ to single-quote attribute delimiters when values contain double quotes (e.g.
31
+ JSON), and validates that tag names are legal kebab-cased custom element names.
32
+ executables: []
33
+ extensions: []
34
+ extra_rdoc_files: []
35
+ files:
36
+ - CHANGELOG.md
37
+ - LICENSE.txt
38
+ - README.md
39
+ - lib/actionview/vue_tag_helper.rb
40
+ - lib/actionview/vue_tag_helper/railtie.rb
41
+ - lib/actionview/vue_tag_helper/version.rb
42
+ - lib/actionview/vue_tag_helper/vue_builder.rb
43
+ homepage: https://github.com/dmke/actionview-vue_tag_helper
44
+ licenses:
45
+ - MIT
46
+ metadata:
47
+ rubygems_mfa_required: 'true'
48
+ homepage_uri: https://github.com/dmke/actionview-vue_tag_helper
49
+ source_code_uri: https://github.com/dmke/actionview-vue_tag_helper
50
+ bug_tracker_uri: https://github.com/dmke/actionview-vue_tag_helper/issues
51
+ changelog_uri: https://github.com/dmke/actionview-vue_tag_helper/blob/main/CHANGELOG.md
52
+ rdoc_options: []
53
+ require_paths:
54
+ - lib
55
+ required_ruby_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '4.0'
60
+ required_rubygems_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: '0'
65
+ requirements: []
66
+ rubygems_version: 4.0.6
67
+ specification_version: 4
68
+ summary: ActionView helper for embedding Vue component islands in server-rendered
69
+ HTML
70
+ test_files: []