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 +7 -0
- data/CHANGELOG.md +15 -0
- data/LICENSE.txt +21 -0
- data/README.md +151 -0
- data/lib/actionview/vue_tag_helper/railtie.rb +20 -0
- data/lib/actionview/vue_tag_helper/version.rb +7 -0
- data/lib/actionview/vue_tag_helper/vue_builder.rb +357 -0
- data/lib/actionview/vue_tag_helper.rb +39 -0
- metadata +70 -0
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 " encoding inside JSON %>
|
|
23
|
+
<%= tag.my_component data: { config: {key: "val"}.to_json } %>
|
|
24
|
+
<%# => <my-component data-config="{"key":"val"}"></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,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 +"+. That is safe but verbose — a
|
|
46
|
+
# JSON blob like +{"key":"value"}+ becomes
|
|
47
|
+
# +data="{"key":"value"}"+.
|
|
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
|
+
# & → &
|
|
57
|
+
# < → <
|
|
58
|
+
# > → >
|
|
59
|
+
# ' → ' (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
|
+
"&" => "&",
|
|
147
|
+
"<" => "<",
|
|
148
|
+
">" => ">",
|
|
149
|
+
"'" => "'",
|
|
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('"', """) 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('"', """) 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("'", "'") : 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: []
|