expo_turbo-rails 0.1.3 → 0.1.4
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 +4 -4
- data/README.md +36 -3
- data/lib/expo_turbo/rails/attributes/helper.rb +24 -0
- data/lib/expo_turbo/rails/controller.rb +3 -1
- data/lib/expo_turbo/rails/route_constraint.rb +41 -0
- data/lib/expo_turbo/rails/template_capabilities.rb +105 -1
- data/lib/expo_turbo/rails/version.rb +1 -1
- data/lib/expo_turbo/rails.rb +2 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ab2beaab9a4d3b8388af91c107877e839dbfec1b07c10efb13fc08e8d16df206
|
|
4
|
+
data.tar.gz: 58a6a45c227b1cbf20128ae1a17e550b032ccd4f2545d05322a2721a37576048
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a5310b102f127ff8f64f42ec7d56360d8834a752dd395637d55cabeca393221b16617714729a9f2467acb39382eaac8ab75414f1ecf0cdc1b8954a377acdcdfc
|
|
7
|
+
data.tar.gz: 0a10cd23ece57b85a58c66cc5256f5288b4a9ffba0a1d8725f7774155e96c6767b7136858d69bbe62a36c3b6d8251c18d3f5b72f8465f37637644daebbc55788
|
data/README.md
CHANGED
|
@@ -5,10 +5,10 @@ The Rails package for Expo Turbo. It registers the distinct `application/vnd.exp
|
|
|
5
5
|
The package validates rendered Expo Turbo documents structurally and rejects blank or duplicate literal IDs across the complete response, including Frame IDs. A controller must declare the components and style tokens it is allowed to render documents; when it does, the same policy also applies to its Frame, Stream, and raw controller-broadcast output. Its optional protected Cable boundary delegates all credentials and resource policy to the host.
|
|
6
6
|
|
|
7
7
|
> [!IMPORTANT]
|
|
8
|
-
> Version [`0.1.
|
|
9
|
-
> release published on 2026-07-
|
|
8
|
+
> Version [`0.1.4`](https://rubygems.org/gems/expo_turbo-rails) is the stable
|
|
9
|
+
> release published on 2026-07-30. Manual VoiceOver, TalkBack, and browser
|
|
10
10
|
> screen-reader evidence remains an explicit follow-up and is not claimed by
|
|
11
|
-
> the `0.1.
|
|
11
|
+
> the `0.1.4` compatibility surface.
|
|
12
12
|
|
|
13
13
|
```ruby
|
|
14
14
|
gem "expo_turbo-rails"
|
|
@@ -18,6 +18,16 @@ gem "expo_turbo-rails"
|
|
|
18
18
|
require "expo_turbo/rails"
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
+
Use the Accept-header route constraint when Expo Turbo and another format share a route family:
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
constraints ExpoTurbo::Rails::RouteConstraint.new do
|
|
25
|
+
get "screens/:step", to: "turbo/documents#show"
|
|
26
|
+
end
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The constraint matches the registered Expo Turbo MIME type and positive `application/*` or `*/*` wildcards. A `q=0` value on the most-specific matching media range rejects the request, even when a less-specific wildcard has a positive quality. Missing, blank, unrelated, and substring-only Accept values do not match.
|
|
30
|
+
|
|
21
31
|
Opt a controller into XML rendering and confine it to one host-owned view root:
|
|
22
32
|
|
|
23
33
|
```ruby
|
|
@@ -46,6 +56,29 @@ end
|
|
|
46
56
|
|
|
47
57
|
The template argument is relative to the configured root; absolute paths, traversal, missing files, and symlink escapes are rejected. The resolved `.xml.erb` source is evaluated as ERB with layouts disabled, rather than served as raw file content. Before it renders, the exact output must be a strict UTF-8 XML document: one root, valid namespaces and attributes, no DTD or processing instruction, and an optional leading UTF-8 XML declaration only. Every literal `id` must also be nonblank and unique across the complete rendered document, including nested Frames. The capability declaration then admits only its exact components (and explicit aliases), exact unprefixed `turbo-frame`, `turbo-stream`, `template`, and `turbo-cable-stream-source` wrappers (including default-namespace elements), and declared `style-tokens`. Style-token lists use the same JavaScript whitespace split, count, duplicate, component, and group-conflict rules as the native adapter. A component must opt into the `style-tokens` attribute, and style-token component lists are canonicalized through aliases. The host declaration must mirror its installed registry and style adapter; it deliberately does not attempt to derive or validate arbitrary Zod props/codecs. Validation never serializes the output, so it does not alter preserved XML text.
|
|
48
58
|
|
|
59
|
+
Do not put multiline values in XML attributes with ordinary ERB interpolation. The client XML parser changes raw tabs and line breaks in an attribute to spaces before component decoding. A later form submission can then save the changed value. Use `expo_turbo_attribute` for each value that can contain this whitespace:
|
|
60
|
+
|
|
61
|
+
```erb
|
|
62
|
+
<CustomerNotes value="<%= expo_turbo_attribute(@order.notes) %>" />
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The helper first HTML-escapes the value. It then writes tabs, carriage returns, and line feeds as `	`, ` `, and ` `. The character references preserve the original value through XML parsing. Use `xml:space="preserve"` instead for multiline element text.
|
|
66
|
+
|
|
67
|
+
The client registry can replace the hand-written component map. Write `registry.capabilityManifestJSON()` to a checked-in or generated file, then configure the controller with `manifest:` instead of `components:`:
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
expo_turbo_template_capabilities(
|
|
71
|
+
manifest: Rails.root.join("config/expo_turbo_manifest.json"),
|
|
72
|
+
max_style_tokens: 5,
|
|
73
|
+
style_tokens: {
|
|
74
|
+
"space:compact" => {components: ["DemoCard"], group: "space"},
|
|
75
|
+
"tone:info" => {components: ["DemoCard"], group: "tone"}
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The versioned manifest contains the registry modules, component tags and aliases, and each component's attribute names. Rails loads it when the controller is configured, rejects a malformed or protocol-incompatible file, derives `style-tokens` support from the declared attribute, and applies the same component validation in every environment. Generate the file in CI and fail on a diff to detect a stale manifest before deployment.
|
|
81
|
+
|
|
49
82
|
For a native Frame GET, read the validated request header and emit an exact matching Frame from the host-owned XML template. `expo_turbo_frame_tag` accepts a nonblank UTF-8 literal ID without control characters, or a model class that it normalizes with Rails' `dom_id`, then delegates tag generation to `turbo-rails`. It deliberately does not install `Turbo::Frames::FrameRequest`, so it does not alter HTML layouts or adopt its raw-header behavior. Before returning, it parses the exact Frame output under a private synthetic root and applies the same configured component/style admission: markup must be valid UTF-8 XML without declarations, DTDs, or processing instructions, and any XML prefix must be declared by the Frame fragment itself. Validation does not serialize or alter the returned `SafeBuffer`, so inline `xml:space="preserve"` text keeps its authored bytes for the native parser.
|
|
50
83
|
|
|
51
84
|
```ruby
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/erb/util"
|
|
4
|
+
require "active_support/core_ext/string/output_safety"
|
|
5
|
+
|
|
6
|
+
module ExpoTurbo
|
|
7
|
+
module Rails
|
|
8
|
+
module Attributes
|
|
9
|
+
module Helper
|
|
10
|
+
XML_WHITESPACE_REFERENCES = {
|
|
11
|
+
"\t" => "	",
|
|
12
|
+
"\n" => " ",
|
|
13
|
+
"\r" => " "
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
def expo_turbo_attribute(value)
|
|
17
|
+
ERB::Util.html_escape(value)
|
|
18
|
+
.gsub(/[\t\n\r]/, XML_WHITESPACE_REFERENCES)
|
|
19
|
+
.html_safe
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -16,6 +16,7 @@ module ExpoTurbo
|
|
|
16
16
|
included do
|
|
17
17
|
class_attribute :expo_turbo_views_path, instance_accessor: false
|
|
18
18
|
class_attribute :expo_turbo_template_capabilities_config, instance_accessor: false
|
|
19
|
+
helper ExpoTurbo::Rails::Attributes::Helper
|
|
19
20
|
helper ExpoTurbo::Rails::Frames::Helper
|
|
20
21
|
helper ExpoTurbo::Rails::DomIds::Helper
|
|
21
22
|
helper ExpoTurbo::Rails::Streams::Helper
|
|
@@ -27,9 +28,10 @@ module ExpoTurbo
|
|
|
27
28
|
self.expo_turbo_views_path = Pathname(path).expand_path
|
|
28
29
|
end
|
|
29
30
|
|
|
30
|
-
def expo_turbo_template_capabilities(components
|
|
31
|
+
def expo_turbo_template_capabilities(components: nil, manifest: nil, style_tokens: {}, max_style_tokens: 5)
|
|
31
32
|
self.expo_turbo_template_capabilities_config = TemplateCapabilities.new(
|
|
32
33
|
components:,
|
|
34
|
+
manifest:,
|
|
33
35
|
style_tokens:,
|
|
34
36
|
max_style_tokens:
|
|
35
37
|
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rack/media_type"
|
|
4
|
+
|
|
5
|
+
module ExpoTurbo
|
|
6
|
+
module Rails
|
|
7
|
+
class RouteConstraint
|
|
8
|
+
ACCEPT_ENTRY = /[^,\s"](?:[^,"]|"[^"]*")*/
|
|
9
|
+
|
|
10
|
+
def matches?(request)
|
|
11
|
+
accept = request.get_header("HTTP_ACCEPT").to_s.strip
|
|
12
|
+
return false if accept.empty?
|
|
13
|
+
|
|
14
|
+
mime_type = Mime[MIME_SYMBOL]
|
|
15
|
+
return false unless mime_type
|
|
16
|
+
|
|
17
|
+
quality = accept.scan(ACCEPT_ENTRY).filter_map do |value|
|
|
18
|
+
begin
|
|
19
|
+
media_range = Rack::MediaType.type(value)
|
|
20
|
+
parsed = Mime::Type.parse(media_range)
|
|
21
|
+
rescue Mime::Type::InvalidMimeType
|
|
22
|
+
next
|
|
23
|
+
end
|
|
24
|
+
next unless parsed.include?(mime_type) || media_range == "*/*"
|
|
25
|
+
|
|
26
|
+
specificity = if media_range == "*/*"
|
|
27
|
+
0
|
|
28
|
+
elsif media_range.end_with?("/*")
|
|
29
|
+
1
|
|
30
|
+
else
|
|
31
|
+
2
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
[specificity, Rack::MediaType.params(value).fetch("q", 1).to_f]
|
|
35
|
+
end.max_by { |specificity, range_quality| [specificity, range_quality] }&.last
|
|
36
|
+
|
|
37
|
+
quality.to_f.positive?
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
3
5
|
module ExpoTurbo
|
|
4
6
|
module Rails
|
|
5
7
|
class TemplateCapabilities
|
|
8
|
+
MANIFEST_VERSION = 1
|
|
6
9
|
PROTOCOL_ELEMENTS = %w[turbo-cable-stream-source turbo-frame turbo-stream template].freeze
|
|
7
10
|
RESERVED_COMPONENT_NAMES = [*PROTOCOL_ELEMENTS, "expo-turbo-fragment"].freeze
|
|
8
11
|
TOKEN_PATTERN = /\A[a-z][a-z0-9-]*(?::[a-z][a-z0-9-]*)*\z/
|
|
@@ -14,7 +17,12 @@ module ExpoTurbo
|
|
|
14
17
|
class ValidationError < StandardError
|
|
15
18
|
end
|
|
16
19
|
|
|
17
|
-
def initialize(components
|
|
20
|
+
def initialize(components: nil, manifest: nil, style_tokens: {}, max_style_tokens: 5)
|
|
21
|
+
if components.nil? == manifest.nil?
|
|
22
|
+
raise ConfigurationError, "Expo Turbo template capabilities require exactly one of components or manifest"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
components = load_manifest_components(manifest) unless manifest.nil?
|
|
18
26
|
@components, @style_token_components = normalize_components(components)
|
|
19
27
|
@style_tokens = normalize_style_tokens(style_tokens)
|
|
20
28
|
@max_style_tokens = validate_max_style_tokens!(max_style_tokens)
|
|
@@ -36,6 +44,102 @@ module ExpoTurbo
|
|
|
36
44
|
|
|
37
45
|
private
|
|
38
46
|
|
|
47
|
+
def load_manifest_components(path)
|
|
48
|
+
manifest = parse_manifest(read_manifest(path))
|
|
49
|
+
unless manifest["manifestVersion"] == MANIFEST_VERSION
|
|
50
|
+
raise ConfigurationError, "Expo Turbo capability manifest version is unsupported"
|
|
51
|
+
end
|
|
52
|
+
unless manifest["protocolVersion"] == PROTOCOL_VERSION
|
|
53
|
+
raise ConfigurationError, "Expo Turbo capability manifest protocol version does not match"
|
|
54
|
+
end
|
|
55
|
+
unless manifest["hash"].is_a?(String) && /\Afnv1a32:[0-9a-f]{8}\z/.match?(manifest["hash"])
|
|
56
|
+
raise ConfigurationError, "Expo Turbo capability manifest hash is invalid"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
validate_manifest_modules!(manifest["modules"])
|
|
60
|
+
normalize_manifest_components(manifest["components"])
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def read_manifest(path)
|
|
64
|
+
path = path.to_path if path.respond_to?(:to_path)
|
|
65
|
+
unless path.is_a?(String) && !path.empty?
|
|
66
|
+
raise ConfigurationError, "Expo Turbo capability manifest path must be a nonblank path"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
File.binread(path).force_encoding(Encoding::UTF_8).tap do |body|
|
|
70
|
+
unless body.valid_encoding?
|
|
71
|
+
raise ConfigurationError, "Expo Turbo capability manifest must be valid UTF-8"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
rescue SystemCallError, ArgumentError
|
|
75
|
+
raise ConfigurationError, "Expo Turbo capability manifest could not be read"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def parse_manifest(body)
|
|
79
|
+
JSON.parse(body).tap do |manifest|
|
|
80
|
+
unless manifest.is_a?(Hash)
|
|
81
|
+
raise ConfigurationError, "Expo Turbo capability manifest must be a JSON object"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
rescue JSON::ParserError
|
|
85
|
+
raise ConfigurationError, "Expo Turbo capability manifest must be valid JSON"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def validate_manifest_modules!(modules)
|
|
89
|
+
unless modules.is_a?(Array)
|
|
90
|
+
raise ConfigurationError, "Expo Turbo capability manifest requires a module list"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
names = {}
|
|
94
|
+
modules.each do |component_module|
|
|
95
|
+
unless component_module.is_a?(Hash) &&
|
|
96
|
+
component_module["name"].is_a?(String) &&
|
|
97
|
+
!component_module["name"].strip.empty? &&
|
|
98
|
+
component_module["version"].is_a?(String) &&
|
|
99
|
+
!component_module["version"].strip.empty?
|
|
100
|
+
raise ConfigurationError, "Expo Turbo capability manifest modules require names and versions"
|
|
101
|
+
end
|
|
102
|
+
if names.key?(component_module["name"])
|
|
103
|
+
raise ConfigurationError, "Expo Turbo capability manifest contains a duplicate module"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
names[component_module["name"]] = true
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def normalize_manifest_components(component_entries)
|
|
111
|
+
unless component_entries.is_a?(Array)
|
|
112
|
+
raise ConfigurationError, "Expo Turbo capability manifest requires a component list"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
component_entries.each_with_object({}) do |component, components|
|
|
116
|
+
unless component.is_a?(Hash) && component["tag"].is_a?(String) && component["aliases"].is_a?(Array)
|
|
117
|
+
raise ConfigurationError, "Expo Turbo capability manifest components require tags and aliases"
|
|
118
|
+
end
|
|
119
|
+
unless component["aliases"].all? { |alias_name| alias_name.is_a?(String) }
|
|
120
|
+
raise ConfigurationError, "Expo Turbo capability manifest aliases must be strings"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
attributes = component["attributes"]
|
|
124
|
+
unless attributes.is_a?(Array) &&
|
|
125
|
+
attributes.all? { |attribute| attribute.is_a?(Hash) && attribute["name"].is_a?(String) && !attribute["name"].empty? }
|
|
126
|
+
raise ConfigurationError, "Expo Turbo capability manifest components require attribute names"
|
|
127
|
+
end
|
|
128
|
+
attribute_names = attributes.map { |attribute| attribute["name"] }
|
|
129
|
+
if attribute_names.uniq.length != attribute_names.length
|
|
130
|
+
raise ConfigurationError, "Expo Turbo capability manifest contains duplicate attributes"
|
|
131
|
+
end
|
|
132
|
+
if components.key?(component["tag"])
|
|
133
|
+
raise ConfigurationError, "Expo Turbo capability manifest contains a duplicate component"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
components[component["tag"]] = {
|
|
137
|
+
aliases: component["aliases"],
|
|
138
|
+
style_tokens: attribute_names.include?("style-tokens")
|
|
139
|
+
}
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
39
143
|
def normalize_components(components)
|
|
40
144
|
raise ConfigurationError, "Expo Turbo template capabilities require a component map" unless components.is_a?(Hash)
|
|
41
145
|
|
data/lib/expo_turbo/rails.rb
CHANGED
|
@@ -7,8 +7,10 @@ require "turbo-rails"
|
|
|
7
7
|
|
|
8
8
|
require_relative "rails/version"
|
|
9
9
|
require_relative "rails/protocol"
|
|
10
|
+
require_relative "rails/route_constraint"
|
|
10
11
|
require_relative "rails/errors"
|
|
11
12
|
require_relative "rails/template_capabilities"
|
|
13
|
+
require_relative "rails/attributes/helper"
|
|
12
14
|
require_relative "rails/frames"
|
|
13
15
|
require_relative "rails/frames/helper"
|
|
14
16
|
require_relative "rails/dom_ids"
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: expo_turbo-rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- NoScrubs
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-07-
|
|
11
|
+
date: 2026-07-30 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: actioncable
|
|
@@ -120,6 +120,7 @@ files:
|
|
|
120
120
|
- LICENSE.txt
|
|
121
121
|
- README.md
|
|
122
122
|
- lib/expo_turbo/rails.rb
|
|
123
|
+
- lib/expo_turbo/rails/attributes/helper.rb
|
|
123
124
|
- lib/expo_turbo/rails/cable.rb
|
|
124
125
|
- lib/expo_turbo/rails/cable/configuration.rb
|
|
125
126
|
- lib/expo_turbo/rails/cable/connection.rb
|
|
@@ -133,6 +134,7 @@ files:
|
|
|
133
134
|
- lib/expo_turbo/rails/frames.rb
|
|
134
135
|
- lib/expo_turbo/rails/frames/helper.rb
|
|
135
136
|
- lib/expo_turbo/rails/protocol.rb
|
|
137
|
+
- lib/expo_turbo/rails/route_constraint.rb
|
|
136
138
|
- lib/expo_turbo/rails/streams.rb
|
|
137
139
|
- lib/expo_turbo/rails/streams/broadcast_job.rb
|
|
138
140
|
- lib/expo_turbo/rails/streams/helper.rb
|