reactive_views 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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +49 -0
  3. data/LICENSE.txt +22 -0
  4. data/README.md +134 -0
  5. data/app/controllers/reactive_views/bundles_controller.rb +47 -0
  6. data/app/frontend/reactive_views/boot.ts +215 -0
  7. data/config/routes.rb +10 -0
  8. data/lib/generators/reactive_views/install/install_generator.rb +645 -0
  9. data/lib/generators/reactive_views/install/templates/application.html.erb.tt +19 -0
  10. data/lib/generators/reactive_views/install/templates/application.js.tt +9 -0
  11. data/lib/generators/reactive_views/install/templates/boot.ts.tt +225 -0
  12. data/lib/generators/reactive_views/install/templates/example_component.tsx.tt +4 -0
  13. data/lib/generators/reactive_views/install/templates/initializer.rb.tt +7 -0
  14. data/lib/generators/reactive_views/install/templates/vite.config.mts.tt +78 -0
  15. data/lib/generators/reactive_views/install/templates/vite.json.tt +22 -0
  16. data/lib/reactive_views/cache_store.rb +269 -0
  17. data/lib/reactive_views/component_resolver.rb +243 -0
  18. data/lib/reactive_views/configuration.rb +71 -0
  19. data/lib/reactive_views/controller_props.rb +43 -0
  20. data/lib/reactive_views/css_strategy.rb +179 -0
  21. data/lib/reactive_views/engine.rb +14 -0
  22. data/lib/reactive_views/error_overlay.rb +1390 -0
  23. data/lib/reactive_views/full_page_renderer.rb +158 -0
  24. data/lib/reactive_views/helpers.rb +209 -0
  25. data/lib/reactive_views/props_builder.rb +42 -0
  26. data/lib/reactive_views/props_inference.rb +89 -0
  27. data/lib/reactive_views/railtie.rb +93 -0
  28. data/lib/reactive_views/renderer.rb +484 -0
  29. data/lib/reactive_views/resolver.rb +66 -0
  30. data/lib/reactive_views/ssr_process.rb +274 -0
  31. data/lib/reactive_views/tag_transformer.rb +523 -0
  32. data/lib/reactive_views/temp_file_manager.rb +81 -0
  33. data/lib/reactive_views/template_handler.rb +52 -0
  34. data/lib/reactive_views/version.rb +5 -0
  35. data/lib/reactive_views.rb +58 -0
  36. data/lib/tasks/reactive_views.rake +104 -0
  37. data/node/ssr/server.mjs +965 -0
  38. data/package-lock.json +516 -0
  39. data/package.json +14 -0
  40. metadata +322 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: bb9182ae58f918f62a36d2c0ea69b4ae30b2c2d6e7a517897b3ce221ea382573
4
+ data.tar.gz: 6828c01ab50233811706d1dfc8c3f1d961e87c72c54e4058942410f9d7cce338
5
+ SHA512:
6
+ metadata.gz: ec6677f6dbe14e769bcec14f3f94bae2584573036fde8dbcc2fe4e2c098bf29d6e7b32b4f3b5bcbadb6d5d0d08baa65eb5265cfe19d5444fe6c09b2f42c3ea4d
7
+ data.tar.gz: 1e66dc6799b26439f3db59644b097faaf9bb52ad720f3e47bb258e0c2e40ada312e9139669ff1c8e48416d711ee1def940bf6020054a05f7bb8eb7a4c3382988
data/CHANGELOG.md ADDED
@@ -0,0 +1,49 @@
1
+ # Changelog
2
+
3
+ All notable changes to the ReactiveViews gem will be documented in this file.
4
+
5
+ ## [Unreleased]
6
+
7
+ ### Fixed
8
+ - **Generator Idempotency**: The install generator can now be run multiple times safely. It properly detects and removes old `vite_client_tag` and `vite_javascript_tag` calls, replacing them with the unified `reactive_views_script_tag` helper.
9
+ - **Helper Robustness**: The `reactive_views_script_tag` helper now gracefully handles cases where `vite_rails` is not available or misconfigured, showing helpful error messages in development and failing silently in production.
10
+ - **Layout Transformation**: Improved regex patterns to catch all variations of Vite tags (extra whitespace, different quote styles, multiline).
11
+
12
+ ### Added
13
+ - **Comprehensive Test Suite**: Full RSpec test coverage including:
14
+ - Unit tests for TagTransformer, Renderer, ComponentResolver, ErrorOverlay, and Helpers
15
+ - Integration tests for component rendering pipeline and generator
16
+ - Dummy Rails app for integration testing using Combustion
17
+ - WebMock for SSR server mocking
18
+ - SimpleCov for code coverage reporting
19
+ - **Rake Tasks**: New rake tasks for test management:
20
+ - `rake reactive_views:test:all` - Run all tests
21
+ - `rake reactive_views:test:unit` - Run unit tests only
22
+ - `rake reactive_views:test:integration` - Run integration tests only
23
+ - `rake reactive_views:test:coverage` - Run with coverage report
24
+ - `rake reactive_views:test:clean` - Clean test artifacts
25
+ - **Testing Documentation**: Comprehensive testing guide in `TESTING.md`
26
+ - **Generator Force Mode**: The install generator now respects `--force` flag to skip prompts during automated setups
27
+
28
+ ### Changed
29
+ - **Generator Update Logic**: The `update_application_layout` method now:
30
+ - Uses multiline regex patterns (`/m` flag) for robust tag detection
31
+ - Removes existing `reactive_views_script_tag` before adding new one to prevent duplicates
32
+ - Better handles edge cases like extra whitespace and comment blocks
33
+ - Provides clearer status messages during updates
34
+
35
+ ### Development
36
+ - Added development dependencies:
37
+ - rspec-rails ~> 6.0
38
+ - capybara ~> 3.39
39
+ - selenium-webdriver ~> 4.16
40
+ - webdrivers ~> 5.3
41
+ - webmock ~> 3.19
42
+ - simplecov ~> 0.22
43
+ - combustion ~> 1.4
44
+ - sqlite3 ~> 1.4
45
+
46
+ ## [Previous Versions]
47
+
48
+ See git history for changes in previous versions.
49
+
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2024 Elison Campos
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
13
+ all 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
21
+ THE SOFTWARE.
22
+
data/README.md ADDED
@@ -0,0 +1,134 @@
1
+ # ReactiveViews
2
+
3
+ [![CI](https://github.com/elisoncampos/reactive_views/actions/workflows/ci.yml/badge.svg)](https://github.com/elisoncampos/reactive_views/actions/workflows/ci.yml)
4
+ [![Gem Version](https://img.shields.io/gem/v/reactive_views)](https://rubygems.org/gems/reactive_views)
5
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+
7
+ ReactiveViews lets you write React components and use them directly inside Rails views — with **server-side rendering (SSR)**, **client hydration**, and an optional **full-page `.tsx.erb` / `.jsx.erb`** pipeline. No separate frontend app required.
8
+
9
+ ## Disclaimer
10
+
11
+ **⚠️ ReactiveViews is in beta.** Expect some breaking changes as the API and defaults settle. If you run it in production, treat the Node SSR runtime as part of your app (monitor it, capture logs, and load test SSR-heavy pages).
12
+
13
+ ## What you get
14
+
15
+ - **React islands in ERB**: write `<UserBadge />` in `.html.erb` and get SSR + hydration.
16
+ - **Full-page React templates**: render entire Rails pages from `.tsx.erb` / `.jsx.erb` while keeping Rails controllers, routes, and layouts.
17
+ - **Fast SSR**: batch rendering for flat pages, tree rendering for nested component composition.
18
+ - **Less prop plumbing**: optional TypeScript-based prop key inference for full-page pages (reduces payload size).
19
+ - **Caching knobs**: SSR HTML caching + inference caching with pluggable cache stores.
20
+ - **Vite-native**: dev HMR + production builds via `vite_rails`.
21
+
22
+ ## A quick taste
23
+
24
+ ### Islands inside ERB
25
+
26
+ ```tsx
27
+ // app/views/components/user_badge.tsx
28
+ type Props = { fullName: string };
29
+
30
+ export default function UserBadge({ fullName }: Props) {
31
+ return <strong className="UserBadge">{fullName}</strong>;
32
+ }
33
+ ```
34
+
35
+ ```erb
36
+ <!-- app/views/users/show.html.erb -->
37
+ <UserBadge fullName="<%= @user.name %>" />
38
+ ```
39
+
40
+ ### Full-page `.tsx.erb`
41
+
42
+ ```ruby
43
+ # app/controllers/users_controller.rb
44
+ class UsersController < ApplicationController
45
+ def index
46
+ @users = User.select(:id, :name).order(:name)
47
+ reactive_view_props(current_user: current_user)
48
+ end
49
+ end
50
+ ```
51
+
52
+ ```tsx
53
+ // app/views/users/index.tsx.erb
54
+ interface Props {
55
+ users: Array<{ id: number; name: string }>;
56
+ current_user: { name: string } | null;
57
+ }
58
+
59
+ export default function UsersIndex({ users, current_user }: Props) {
60
+ return (
61
+ <main>
62
+ <h1>Users</h1>
63
+ {current_user ? (
64
+ <p>Signed in as {current_user.name}</p>
65
+ ) : (
66
+ <p>Not signed in</p>
67
+ )}
68
+ <ul>
69
+ {users.map((u) => (
70
+ <li key={u.id}>{u.name}</li>
71
+ ))}
72
+ </ul>
73
+ </main>
74
+ );
75
+ }
76
+ ```
77
+
78
+ ## Install (5 minutes)
79
+
80
+ 1. Add the gem + install:
81
+
82
+ ```ruby
83
+ gem "reactive_views"
84
+ ```
85
+
86
+ ```bash
87
+ bundle install
88
+ ```
89
+
90
+ 2. Run the installer:
91
+
92
+ ```bash
93
+ bundle exec rails generate reactive_views:install
94
+ ```
95
+
96
+ 3. Start dev:
97
+
98
+ ```bash
99
+ bin/dev
100
+ ```
101
+
102
+ That runs Rails + Vite + the SSR server together.
103
+
104
+ For the layout helper and a full walkthrough, see [`docs/getting-started.md`](docs/getting-started.md).
105
+
106
+ ## Documentation
107
+
108
+ Start at [`docs/README.md`](docs/README.md). Handy links:
109
+
110
+ - **Quick start:** [`docs/getting-started.md`](docs/getting-started.md)
111
+ - **Islands in ERB:** [`docs/islands.md`](docs/islands.md)
112
+ - **Full-page `.tsx.erb` / `.jsx.erb`:** [`docs/full-page-tsx-erb.md`](docs/full-page-tsx-erb.md)
113
+ - **Configuration:** [`docs/configuration.md`](docs/configuration.md)
114
+ - **SSR runtime & process management:** [`docs/ssr.md`](docs/ssr.md)
115
+ - **Caching:** [`docs/caching.md`](docs/caching.md)
116
+ - **Production deployment:** [`docs/production-deployment.md`](docs/production-deployment.md)
117
+ - **Troubleshooting:** [`docs/troubleshooting.md`](docs/troubleshooting.md)
118
+ - **Security considerations:** [`docs/security.md`](docs/security.md)
119
+ - **API reference:** [`docs/api-reference.md`](docs/api-reference.md)
120
+
121
+ ## Before you ship
122
+
123
+ - Read [`docs/security.md`](docs/security.md) (props are public; SSR should stay private).
124
+ - Read [`docs/production-deployment.md`](docs/production-deployment.md) (assets, SSR process strategy, what to monitor).
125
+
126
+ ## Contributing
127
+
128
+ At this time, we are only accepting bug reports. If you encounter any issues or have suggestions, please open an issue on our [GitHub repository](https://github.com/elisoncampos/reactive_views/issues).
129
+
130
+ We appreciate your feedback and contributions to help improve ReactiveViews!
131
+
132
+ ## License
133
+
134
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+
5
+ module ReactiveViews
6
+ class BundlesController < ActionController::Base
7
+ # Proxy full-page bundle requests to the local SSR server.
8
+ # This allows the client to fetch bundles via same-origin requests,
9
+ # even when the SSR server is only listening on localhost.
10
+ def show
11
+ bundle_key = params[:id]
12
+
13
+ # Validate bundle key format (should be a SHA1 hash)
14
+ unless bundle_key.match?(/\A[a-f0-9]{40}\z/)
15
+ render plain: "Invalid bundle key", status: :bad_request
16
+ return
17
+ end
18
+
19
+ # Ensure SSR process is running
20
+ SsrProcess.ensure_running
21
+
22
+ # Proxy the request to the SSR server
23
+ ssr_url = ReactiveViews.config.ssr_url
24
+ uri = URI.parse("#{ssr_url}/full-page-bundles/#{bundle_key}.js")
25
+
26
+ begin
27
+ http = Net::HTTP.new(uri.host, uri.port)
28
+ http.open_timeout = 5
29
+ http.read_timeout = 10
30
+
31
+ response = http.get(uri.path)
32
+
33
+ if response.is_a?(Net::HTTPSuccess)
34
+ # Stream the JavaScript bundle back to the client
35
+ render body: response.body,
36
+ content_type: "application/javascript",
37
+ status: :ok
38
+ else
39
+ render plain: "Bundle not found", status: :not_found
40
+ end
41
+ rescue Errno::ECONNREFUSED, Net::OpenTimeout, Net::ReadTimeout => e
42
+ Rails.logger.error("[ReactiveViews] Bundle proxy error: #{e.message}")
43
+ render plain: "SSR server unavailable", status: :service_unavailable
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,215 @@
1
+ import React from 'react';
2
+ import { hydrateRoot } from 'react-dom/client';
3
+
4
+ type IslandSpec = {
5
+ id: string;
6
+ component: string;
7
+ el: Element;
8
+ };
9
+
10
+ type ReactiveViewsGlobal = {
11
+ react?: typeof React;
12
+ hydrateRoot?: typeof hydrateRoot;
13
+ ssrUrl?: string;
14
+ hydratedPages?: string[];
15
+ lastPageError?: string;
16
+ };
17
+
18
+ declare global {
19
+ interface Window {
20
+ __REACTIVE_VIEWS__?: ReactiveViewsGlobal;
21
+ }
22
+ }
23
+
24
+ const globalRV: ReactiveViewsGlobal = (window.__REACTIVE_VIEWS__ ||= {});
25
+ globalRV.react = React;
26
+ globalRV.hydrateRoot = hydrateRoot;
27
+ globalRV.hydratedPages ||= [];
28
+
29
+ function readProps(uuid: string) {
30
+ const script = document.querySelector(`script[type="application/json"][data-island-uuid="${uuid}"]`);
31
+ if (!script) return {};
32
+ try {
33
+ return JSON.parse(script.textContent || '{}');
34
+ } catch (_e) {
35
+ return {};
36
+ }
37
+ }
38
+
39
+ type PageMetadata = {
40
+ props?: Record<string, unknown>;
41
+ bundle?: string;
42
+ };
43
+
44
+ function readPageMetadata(uuid: string): PageMetadata {
45
+ const script = document.querySelector(`script[type="application/json"][data-page-uuid="${uuid}"]`);
46
+ if (!script) return {};
47
+ try {
48
+ return JSON.parse(script.textContent || '{}');
49
+ } catch (_e) {
50
+ return {};
51
+ }
52
+ }
53
+
54
+ function toPath(name: string): string {
55
+ // Convert PascalCase and dot notation to snake_case path
56
+ const parts = name.split('.');
57
+ const snake = (s: string) => s.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2').replace(/([a-z\d])([A-Z])/g, '$1_$2').replace(/-/g, '_').toLowerCase();
58
+ return parts.map(snake).join('/');
59
+ }
60
+
61
+ // Import all components eagerly so they are available for hydration
62
+ const rawComponentsFromViews = import.meta.glob('../../views/components/**/*.{tsx,jsx,ts,js}', {
63
+ eager: true,
64
+ });
65
+ const rawComponentsFromJs = import.meta.glob('../components/**/*.{tsx,jsx,ts,js}', {
66
+ eager: true,
67
+ });
68
+
69
+ // Normalize paths to match the absolute aliased paths that loadComponent expects
70
+ const componentsFromViews: Record<string, any> = {};
71
+ const componentsFromJs: Record<string, any> = {};
72
+
73
+ for (const [path, module] of Object.entries(rawComponentsFromViews)) {
74
+ const normalized = path.replace('../../views/components/', '/app/views/components/');
75
+ componentsFromViews[normalized] = (module as any).default;
76
+ }
77
+
78
+ for (const [path, module] of Object.entries(rawComponentsFromJs)) {
79
+ const normalized = path.replace('../components/', '/app/javascript/components/');
80
+ componentsFromJs[normalized] = (module as any).default;
81
+ }
82
+
83
+ async function loadComponent(name: string) {
84
+ // Attempt both conventional locations; try multiple filename shapes and extensions
85
+ const rel = toPath(name);
86
+ const leaf = rel.split('/').pop() as string;
87
+ const bases = ['/app/views/components', '/app/javascript/components'];
88
+ const exts = ['.tsx', '.jsx', '.ts', '.js'];
89
+
90
+ // Combine both glob maps
91
+ const allComponents = { ...componentsFromViews, ...componentsFromJs };
92
+
93
+ // Try both the original name (e.g., "InteractiveCounter") and snake_case ("interactive_counter")
94
+ const nameVariants = [name, rel];
95
+ const leafVariants = [name, leaf];
96
+
97
+ for (const base of bases) {
98
+ for (let i = 0; i < nameVariants.length; i++) {
99
+ const nameVar = nameVariants[i];
100
+ const leafVar = leafVariants[i];
101
+
102
+ for (const ext of exts) {
103
+ const candidates = [
104
+ `${base}/${nameVar}${ext}`,
105
+ `${base}/${nameVar}/index${ext}`,
106
+ `${base}/${nameVar}/${leafVar}${ext}`,
107
+ ];
108
+ for (const path of candidates) {
109
+ // Check if this path exists in our glob imports
110
+ if (allComponents[path]) {
111
+ // Component is already loaded with eager: true
112
+ // The .default was already extracted during normalization
113
+ return allComponents[path];
114
+ }
115
+ }
116
+ }
117
+ }
118
+ }
119
+
120
+ throw new Error(`Component not found on client: ${name}`);
121
+ }
122
+
123
+ function resolveSsrUrl() {
124
+ if (globalRV.ssrUrl) return globalRV.ssrUrl.replace(/\/$/, '');
125
+ const meta = document.querySelector('meta[name="reactive-views-ssr-url"]');
126
+ if (meta?.getAttribute('content')) {
127
+ return meta.getAttribute('content')!.replace(/\/$/, '');
128
+ }
129
+ return 'http://localhost:5175';
130
+ }
131
+
132
+ async function hydrateFullPages() {
133
+ const nodes = Array.from(document.querySelectorAll<HTMLElement>('[data-reactive-page="true"]'));
134
+ for (const node of nodes) {
135
+ const uuid = node.dataset.pageUuid;
136
+ if (!uuid) continue;
137
+
138
+ // Skip if already hydrated
139
+ if (node.dataset.reactiveHydrated === 'true') continue;
140
+
141
+ const metadata = readPageMetadata(uuid);
142
+ if (!metadata.bundle) continue;
143
+
144
+ const ssrUrl = resolveSsrUrl();
145
+ const moduleUrl = `${ssrUrl}/full-page-bundles/${metadata.bundle}.js`;
146
+
147
+ try {
148
+ if (import.meta.env?.DEV) {
149
+ console.log(`[reactive_views] Hydrating full page bundle: ${metadata.bundle}`);
150
+ }
151
+ const mod = await import(/* @vite-ignore */ moduleUrl);
152
+ const Comp = mod.default || mod.Component || mod;
153
+ hydrateRoot(node, React.createElement(Comp, metadata.props || {}));
154
+ if (import.meta.env?.DEV) {
155
+ console.log(`[reactive_views] Hydrated full page bundle ${metadata.bundle}`);
156
+ }
157
+ globalRV.hydratedPages!.push(uuid);
158
+ node.dataset.reactiveHydrated = "true";
159
+ } catch (e) {
160
+ if (import.meta.env?.DEV) {
161
+ console.error(`[reactive_views] Failed to hydrate full page bundle: ${metadata.bundle}`, e);
162
+ }
163
+ globalRV.lastPageError = e instanceof Error ? e.stack : String(e);
164
+ }
165
+ }
166
+ }
167
+
168
+ async function hydrateIslands() {
169
+ const nodes = Array.from(document.querySelectorAll('[data-island-uuid][data-component]'));
170
+ for (const node of nodes) {
171
+ const el = node as HTMLElement;
172
+
173
+ // Skip if already hydrated
174
+ if (el.dataset.reactiveHydrated === 'true') continue;
175
+
176
+ const uuid = el.dataset.islandUuid!;
177
+ const component = el.dataset.component!;
178
+ try {
179
+ const Comp = await loadComponent(component);
180
+ const props = readProps(uuid);
181
+ hydrateRoot(el, React.createElement(Comp, props));
182
+ el.dataset.reactiveHydrated = 'true';
183
+ } catch (e) {
184
+ if (import.meta.env?.DEV) {
185
+ console.error(`[reactive_views] Failed to hydrate component: ${component}`, e);
186
+ console.error(`[reactive_views] Make sure vite.config.ts has resolve.alias configured for /app/views/components and /app/javascript/components`);
187
+ }
188
+ }
189
+ }
190
+ }
191
+
192
+ async function hydrateAll() {
193
+ await hydrateFullPages();
194
+ await hydrateIslands();
195
+ }
196
+
197
+ // Initial hydration
198
+ if (document.readyState === 'loading') {
199
+ document.addEventListener('DOMContentLoaded', hydrateAll);
200
+ } else {
201
+ hydrateAll();
202
+ }
203
+
204
+ // Turbo integration: re-hydrate after Turbo navigations
205
+ // This ensures React components work correctly with Turbo Drive and Turbo Frames
206
+ document.addEventListener('turbo:load', hydrateAll);
207
+ document.addEventListener('turbo:frame-load', hydrateAll);
208
+
209
+ // Clean up before Turbo caches the page for back/forward navigation
210
+ // Without this, restored pages have data-reactive-hydrated="true" but no actual React instances
211
+ document.addEventListener('turbo:before-cache', () => {
212
+ document.querySelectorAll('[data-reactive-hydrated]').forEach(el => {
213
+ el.removeAttribute('data-reactive-hydrated');
214
+ });
215
+ });
data/config/routes.rb ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ ReactiveViews::Engine.routes.draw do
4
+ # Proxy full-page bundle requests to the SSR server
5
+ # The client requests: /reactive_views/full-page-bundles/:bundle_key.js
6
+ # We match both with and without the .js extension for flexibility
7
+ get "full-page-bundles/:id", to: "bundles#show", as: :bundle,
8
+ constraints: { id: /[a-f0-9]+/ },
9
+ defaults: { format: "js" }
10
+ end