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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 879d2dc22ae296a562bec85a13416fc54a99af71451a9301a933e70fe2e526f7
4
- data.tar.gz: 5e17db2c9cd79b5da438b6a2896a2cbe711b3222649453d1df2427a5d84db28b
3
+ metadata.gz: ab2beaab9a4d3b8388af91c107877e839dbfec1b07c10efb13fc08e8d16df206
4
+ data.tar.gz: 58a6a45c227b1cbf20128ae1a17e550b032ccd4f2545d05322a2721a37576048
5
5
  SHA512:
6
- metadata.gz: d638831a71e7c9a957c9deb5aa43b1d7d9329fa77e954bf692d71b97929685105b7cd812ab3edae9668a7e1801f9f3f71dd5b471959d6c2bd272798e5f9ed815
7
- data.tar.gz: 4dda22e4406787f8b1380801fe7191720cd342a37346e892229d08f0b6c8406c3506f0dff5dea375eaa8f583e497bc39c06bd76952ef7164bf8a87fa46b0d242
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.0`](https://rubygems.org/gems/expo_turbo-rails) is the stable
9
- > release published on 2026-07-27. Manual VoiceOver, TalkBack, and browser
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.0` compatibility surface.
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 `&#9;`, `&#13;`, and `&#10;`. 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" => "&#9;",
12
+ "\n" => "&#10;",
13
+ "\r" => "&#13;"
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:, style_tokens: {}, max_style_tokens: 5)
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:, style_tokens: {}, max_style_tokens: 5)
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
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ExpoTurbo
4
4
  module Rails
5
- VERSION = "0.1.3"
5
+ VERSION = "0.1.4"
6
6
  end
7
7
  end
@@ -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.3
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-27 00:00:00.000000000 Z
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