islandjs-rails 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.
@@ -0,0 +1,271 @@
1
+ require 'json'
2
+ require 'digest'
3
+ require 'fileutils'
4
+
5
+ module IslandjsRails
6
+ class VendorManager
7
+ attr_reader :configuration
8
+
9
+ def initialize(configuration)
10
+ @configuration = configuration
11
+ end
12
+
13
+ # Install a package to vendor directory
14
+ def install_package!(package_name, version = nil)
15
+ puts "📦 Installing #{package_name} to vendor directory..."
16
+
17
+ # Download UMD content
18
+ content, actual_version = download_umd_content(package_name, version)
19
+ return false unless content
20
+
21
+ # Ensure vendor directory exists
22
+ FileUtils.mkdir_p(configuration.vendor_dir)
23
+
24
+ # Save to vendor file
25
+ vendor_file = configuration.vendor_file_path(package_name, actual_version)
26
+ File.write(vendor_file, content)
27
+
28
+ # Update manifest
29
+ update_manifest!(package_name, actual_version, File.basename(vendor_file))
30
+
31
+ # Regenerate vendor partial
32
+ regenerate_vendor_partial!
33
+
34
+ puts "✅ #{package_name}@#{actual_version} installed to vendor"
35
+ true
36
+ end
37
+
38
+ # Remove a package from vendor directory
39
+ def remove_package!(package_name)
40
+ puts "🗑️ Removing #{package_name} from vendor..."
41
+
42
+ manifest = read_manifest
43
+ lib_entry = manifest['libs'].find { |lib| lib['name'] == package_name }
44
+
45
+ return false unless lib_entry
46
+
47
+ # Remove file
48
+ vendor_file = configuration.vendor_dir.join(lib_entry['file'])
49
+ File.delete(vendor_file) if File.exist?(vendor_file)
50
+
51
+ # Update manifest
52
+ manifest['libs'].reject! { |lib| lib['name'] == package_name }
53
+ write_manifest(manifest)
54
+
55
+ # Regenerate vendor partial
56
+ regenerate_vendor_partial!
57
+
58
+ puts "✅ #{package_name} removed from vendor"
59
+ true
60
+ end
61
+
62
+ # Rebuild combined bundle (for :external_combined mode)
63
+ def rebuild_combined_bundle!
64
+ return unless configuration.vendor_script_mode == :external_combined
65
+
66
+ puts "🔨 Building combined vendor bundle..."
67
+
68
+ manifest = read_manifest
69
+ return if manifest['libs'].empty?
70
+
71
+ # Order libraries according to vendor_order
72
+ ordered_libs = order_libraries(manifest['libs'])
73
+
74
+ # Combine all UMD content
75
+ combined_content = build_combined_content(ordered_libs)
76
+
77
+ # Generate hash for cache busting
78
+ content_hash = Digest::SHA256.hexdigest(combined_content)[0, 12]
79
+
80
+ # Write combined file
81
+ combined_file = configuration.combined_vendor_path(content_hash)
82
+ File.write(combined_file, combined_content)
83
+
84
+ # Update manifest with combined info
85
+ manifest['combined'] = {
86
+ 'hash' => content_hash,
87
+ 'file' => File.basename(combined_file),
88
+ 'size_kb' => (combined_content.bytesize / 1024.0).round(1)
89
+ }
90
+ write_manifest(manifest)
91
+
92
+ # Warn if bundle is too large
93
+ size_mb = combined_content.bytesize / (1024.0 * 1024.0)
94
+ if size_mb > 1.0
95
+ puts "⚠️ Warning: Combined bundle is #{size_mb.round(1)}MB - consider splitting libraries"
96
+ end
97
+
98
+ # Clean up old combined files
99
+ cleanup_old_combined_files!
100
+
101
+ # Regenerate vendor partial
102
+ regenerate_vendor_partial!
103
+
104
+ puts "✅ Combined bundle built: #{content_hash}"
105
+ true
106
+ end
107
+
108
+ private
109
+
110
+ def download_umd_content(package_name, version = nil)
111
+ # Use existing UMD download logic from core
112
+ core = IslandjsRails.core
113
+
114
+ # Try to find working UMD URL
115
+ version ||= core.version_for(package_name) || 'latest'
116
+ url = core.find_working_island_url(package_name, version)
117
+
118
+ return [nil, nil] unless url
119
+
120
+ content = core.download_umd_content(url)
121
+ return [nil, nil] unless content
122
+
123
+ # Extract actual version from URL if needed
124
+ actual_version = extract_version_from_url(url) || version
125
+
126
+ [content, actual_version]
127
+ end
128
+
129
+ def extract_version_from_url(url)
130
+ # Extract version from CDN URLs like unpkg.com/react@18.2.0/...
131
+ match = url.match(/@([^\/]+)\//)
132
+ match ? match[1] : nil
133
+ end
134
+
135
+ def read_manifest
136
+ manifest_path = configuration.vendor_manifest_path
137
+
138
+ if File.exist?(manifest_path)
139
+ begin
140
+ JSON.parse(File.read(manifest_path))
141
+ rescue JSON::ParserError
142
+ { 'libs' => [] }
143
+ end
144
+ else
145
+ { 'libs' => [] }
146
+ end
147
+ end
148
+
149
+ def write_manifest(manifest)
150
+ FileUtils.mkdir_p(File.dirname(configuration.vendor_manifest_path))
151
+ File.write(configuration.vendor_manifest_path, JSON.pretty_generate(manifest))
152
+ end
153
+
154
+ def update_manifest!(package_name, version, filename)
155
+ manifest = read_manifest
156
+
157
+ # Remove existing entry for this package
158
+ manifest['libs'].reject! { |lib| lib['name'] == package_name }
159
+
160
+ # Add new entry
161
+ manifest['libs'] << {
162
+ 'name' => package_name,
163
+ 'version' => version,
164
+ 'file' => filename
165
+ }
166
+
167
+ write_manifest(manifest)
168
+ end
169
+
170
+ def order_libraries(libs)
171
+ # Sort according to vendor_order, then alphabetically
172
+ ordered = []
173
+ remaining = libs.dup
174
+
175
+ # Add libraries in vendor_order first
176
+ configuration.vendor_order.each do |name|
177
+ lib = remaining.find { |l| l['name'] == name }
178
+ if lib
179
+ ordered << lib
180
+ remaining.delete(lib)
181
+ end
182
+ end
183
+
184
+ # Add remaining libraries alphabetically
185
+ ordered + remaining.sort_by { |lib| lib['name'] }
186
+ end
187
+
188
+ def build_combined_content(ordered_libs)
189
+ content_parts = []
190
+
191
+ ordered_libs.each do |lib|
192
+ vendor_file = configuration.vendor_dir.join(lib['file'])
193
+ next unless File.exist?(vendor_file)
194
+
195
+ # Add header comment
196
+ content_parts << "// #{lib['name']}@#{lib['version']}"
197
+
198
+ # Add library content
199
+ content_parts << File.read(vendor_file)
200
+
201
+ # Add separator
202
+ content_parts << ""
203
+ end
204
+
205
+ content_parts.join("\n")
206
+ end
207
+
208
+ def cleanup_old_combined_files!
209
+ # Keep only the 2 most recent combined files
210
+ pattern = configuration.vendor_dir.join("#{configuration.combined_basename}-*.js")
211
+ combined_files = Dir.glob(pattern).sort_by { |f| File.mtime(f) }.reverse
212
+
213
+ # Delete all but the 2 most recent
214
+ combined_files[2..-1]&.each do |file|
215
+ File.delete(file)
216
+ puts " 🗑️ Cleaned up old combined file: #{File.basename(file)}"
217
+ end
218
+ end
219
+
220
+ def regenerate_vendor_partial!
221
+ case configuration.vendor_script_mode
222
+ when :external_split
223
+ generate_split_partial!
224
+ when :external_combined
225
+ generate_combined_partial!
226
+ else
227
+ raise "Unknown vendor_script_mode: #{configuration.vendor_script_mode}"
228
+ end
229
+ end
230
+
231
+ def generate_split_partial!
232
+ manifest = read_manifest
233
+
234
+ content = <<~ERB
235
+ <%# IslandJS Rails Vendor UMD Scripts (Split Mode) %>
236
+ <%# Generated automatically - do not edit manually %>
237
+ <% # Load each library separately for better caching %>
238
+ ERB
239
+
240
+ manifest['libs'].each do |lib|
241
+ content += <<~ERB
242
+ <script src="/islands/vendor/#{lib['file']}" data-turbo-track="reload"></script>
243
+ ERB
244
+ end
245
+
246
+ write_vendor_partial(content)
247
+ end
248
+
249
+ def generate_combined_partial!
250
+ manifest = read_manifest
251
+ combined_info = manifest['combined']
252
+
253
+ return generate_split_partial! unless combined_info
254
+
255
+ content = <<~ERB
256
+ <%# IslandJS Rails Vendor UMD Scripts (Combined Mode) %>
257
+ <%# Generated automatically - do not edit manually %>
258
+ <%# Combined bundle: #{combined_info['size_kb']}KB %>
259
+ <script src="/islands/vendor/#{combined_info['file']}" data-turbo-track="reload"></script>
260
+ ERB
261
+
262
+ write_vendor_partial(content)
263
+ end
264
+
265
+ def write_vendor_partial(content)
266
+ FileUtils.mkdir_p(File.dirname(configuration.vendor_partial_path))
267
+ File.write(configuration.vendor_partial_path, content)
268
+ puts " ✓ Generated vendor partial: #{configuration.vendor_partial_path}"
269
+ end
270
+ end
271
+ end
@@ -0,0 +1,3 @@
1
+ module IslandjsRails
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,142 @@
1
+ require_relative "islandjs_rails/version"
2
+ require_relative "islandjs_rails/configuration"
3
+ require_relative "islandjs_rails/core"
4
+ require_relative "islandjs_rails/vendor_manager"
5
+ require_relative "islandjs_rails/cli"
6
+
7
+ # Conditionally require Rails-specific components
8
+ if defined?(Rails)
9
+ require_relative "islandjs_rails/railtie"
10
+ require_relative "islandjs_rails/rails_helpers"
11
+ end
12
+
13
+ module IslandjsRails
14
+ # Custom error classes
15
+ class Error < StandardError; end
16
+ class YarnError < Error; end
17
+ class PackageNotFoundError < Error; end
18
+ class UmdNotFoundError < Error; end
19
+
20
+ # Constants for compatibility with tests
21
+ UMD_PATH_PATTERNS = [
22
+ 'umd/{name}.production.min.js',
23
+ 'umd/{name}.development.js',
24
+ 'umd/{name}.min.js',
25
+ 'umd/{name}.js',
26
+ 'dist/{name}.min.js',
27
+ 'dist/{name}.js',
28
+ 'dist/{name}.umd.min.js',
29
+ 'dist/{name}.umd.js',
30
+ 'lib/index.iife.min.js', # Solana Web3.js pattern
31
+ 'lib/index.iife.js', # Solana Web3.js pattern
32
+ 'lib/{name}.js',
33
+ 'lib/{name}.min.js',
34
+ '{name}.min.js',
35
+ '{name}.js',
36
+ 'build/{name}.min.js',
37
+ 'build/{name}.js',
38
+ 'bundles/{name}.min.js',
39
+ 'bundles/{name}.js'
40
+ ].freeze
41
+
42
+ CDN_BASES = [
43
+ 'https://unpkg.com',
44
+ 'https://cdn.jsdelivr.net/npm'
45
+ ].freeze
46
+
47
+ BUILT_IN_GLOBAL_NAME_OVERRIDES = {
48
+ # React ecosystem
49
+ 'react' => 'React',
50
+ 'react-dom' => 'ReactDOM',
51
+ 'react-router' => 'ReactRouter',
52
+ 'react-router-dom' => 'ReactRouterDOM',
53
+
54
+ # Utility libraries
55
+ 'lodash' => '_',
56
+ 'underscore' => '_',
57
+ 'jquery' => '$',
58
+ 'zepto' => '$',
59
+ 'date-fns' => 'dateFns',
60
+
61
+ # Frameworks
62
+ 'vue' => 'Vue',
63
+ 'angular' => 'ng',
64
+
65
+ # Blockchain
66
+ '@solana/web3.js' => 'solanaWeb3',
67
+ 'web3' => 'Web3',
68
+
69
+ # Visualization
70
+ 'chart.js' => 'Chart',
71
+ 'plotly.js' => 'Plotly',
72
+
73
+ # State management
74
+ 'redux' => 'Redux'
75
+ }.freeze
76
+
77
+ class << self
78
+ # Configuration management
79
+ def configuration
80
+ @configuration ||= Configuration.new
81
+ end
82
+
83
+ def configure
84
+ yield(configuration)
85
+ end
86
+
87
+ # Core instance management
88
+ def core
89
+ @core ||= Core.new
90
+ end
91
+
92
+ # Vendor manager instance
93
+ def vendor_manager
94
+ @vendor_manager ||= VendorManager.new(configuration)
95
+ end
96
+
97
+ # Delegate common methods to core
98
+ def init!
99
+ core.init!
100
+ end
101
+
102
+ def install!(package_name, version = nil)
103
+ core.install!(package_name, version)
104
+ end
105
+
106
+ def update!(package_name, version = nil)
107
+ core.update!(package_name, version)
108
+ end
109
+
110
+ def remove!(package_name)
111
+ core.remove!(package_name)
112
+ end
113
+
114
+ def sync!
115
+ core.sync!
116
+ end
117
+
118
+ def status!
119
+ core.status!
120
+ end
121
+
122
+ def clean!
123
+ core.clean!
124
+ end
125
+
126
+ def package_installed?(package_name)
127
+ core.package_installed?(package_name)
128
+ end
129
+
130
+ def version_for(library_name)
131
+ core.version_for(library_name)
132
+ end
133
+
134
+ def detect_global_name(package_name)
135
+ core.detect_global_name(package_name)
136
+ end
137
+
138
+ def find_working_island_url(package_name, version)
139
+ core.find_working_island_url(package_name, version)
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,9 @@
1
+ class IslandjsDemoController < ApplicationController
2
+ def index
3
+ # IslandJS Rails demo homepage
4
+ end
5
+
6
+ def react
7
+ # Demo route for showcasing IslandJS React integration
8
+ end
9
+ end
@@ -0,0 +1,117 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { useTurboProps, useTurboCache } from '../utils/turbo.js';
3
+
4
+ const HelloWorld = ({ containerId }) => {
5
+ // Get initial state from the div's data-initial-state attribute
6
+ const initialProps = useTurboProps(containerId);
7
+
8
+ // Component state with defaults, restored from turbo cache if available
9
+ const [count, setCount] = useState(initialProps.count || 0);
10
+ const [message, setMessage] = useState(initialProps.message || "Hello from IslandJS Rails!");
11
+ const [customMessage, setCustomMessage] = useState(initialProps.customMessage || '');
12
+
13
+ // Current state object for turbo caching
14
+ const currentState = {
15
+ count,
16
+ message,
17
+ customMessage
18
+ };
19
+
20
+ // Setup turbo cache persistence - this should run on mount and whenever state changes
21
+ useEffect(() => {
22
+ const cleanup = useTurboCache(containerId, currentState, true);
23
+ return cleanup;
24
+ }, [containerId, count, message, customMessage]);
25
+
26
+ const handleMessageChange = (e) => {
27
+ setCustomMessage(e.target.value);
28
+ };
29
+
30
+ const applyCustomMessage = () => {
31
+ if (customMessage.trim()) {
32
+ setMessage(customMessage.trim());
33
+ setCustomMessage('');
34
+ }
35
+ };
36
+
37
+ return (
38
+ <div style={{
39
+ padding: '20px',
40
+ border: '2px solid #4F46E5',
41
+ borderRadius: '8px',
42
+ backgroundColor: '#F8FAFC',
43
+ textAlign: 'center',
44
+ fontFamily: 'system-ui, sans-serif'
45
+ }}>
46
+ <h2 style={{ color: '#4F46E5', margin: '0 0 16px 0' }}>
47
+ 🏝️ React + IslandjsRails (Turbo-Cache Compatible)
48
+ </h2>
49
+ <p style={{ margin: '0 0 16px 0', fontSize: '18px' }}>
50
+ {message}
51
+ </p>
52
+
53
+ <div style={{ margin: '16px 0' }}>
54
+ <input
55
+ type="text"
56
+ placeholder="Enter custom message"
57
+ value={customMessage}
58
+ onChange={handleMessageChange}
59
+ style={{
60
+ padding: '8px',
61
+ marginRight: '8px',
62
+ border: '1px solid #D1D5DB',
63
+ borderRadius: '4px',
64
+ fontSize: '14px'
65
+ }}
66
+ />
67
+ <button
68
+ onClick={applyCustomMessage}
69
+ style={{
70
+ padding: '8px 16px',
71
+ backgroundColor: '#059669',
72
+ color: 'white',
73
+ border: 'none',
74
+ borderRadius: '4px',
75
+ cursor: 'pointer',
76
+ fontSize: '14px',
77
+ marginRight: '8px'
78
+ }}
79
+ >
80
+ Apply Message
81
+ </button>
82
+ </div>
83
+
84
+ <button
85
+ onClick={() => setCount(count + 1)}
86
+ style={{
87
+ padding: '8px 16px',
88
+ backgroundColor: '#4F46E5',
89
+ color: 'white',
90
+ border: 'none',
91
+ borderRadius: '4px',
92
+ cursor: 'pointer',
93
+ fontSize: '16px'
94
+ }}
95
+ >
96
+ Clicked {count} times
97
+ </button>
98
+
99
+ <div style={{
100
+ marginTop: '16px',
101
+ fontSize: '12px',
102
+ color: '#6B7280',
103
+ textAlign: 'left',
104
+ backgroundColor: '#F9FAFB',
105
+ padding: '12px',
106
+ borderRadius: '4px'
107
+ }}>
108
+ <strong>Turbo-Cache Demo:</strong>
109
+ <br />• Navigate away and back - your count and message persist!
110
+ <br />• Container ID: <code>{containerId}</code>
111
+ <br />• State: <code>{JSON.stringify(currentState)}</code>
112
+ </div>
113
+ </div>
114
+ );
115
+ };
116
+
117
+ export default HelloWorld;
@@ -0,0 +1,10 @@
1
+ // IslandJS Rails - Main entry point
2
+ // This file is the webpack entry point for your JavaScript islands
3
+
4
+ // React component imports
5
+ import HelloWorld from './components/HelloWorld.jsx';
6
+
7
+ // Mount components to the global islandjsRails namespace
8
+ window.islandjsRails = {
9
+ HelloWorld
10
+ };
@@ -0,0 +1,87 @@
1
+ // Turbo-compatible state management utilities for React components
2
+
3
+ /**
4
+ * Get initial state from a container's data-initial-state attribute
5
+ * @param {string} containerId - The ID of the container element
6
+ * @returns {Object} - Parsed initial state object
7
+ */
8
+ export function useTurboProps(containerId) {
9
+ const container = document.getElementById(containerId);
10
+ if (!container) {
11
+ console.warn(`IslandJS Turbo: Container ${containerId} not found`);
12
+ return {};
13
+ }
14
+
15
+ const initialStateJson = container.dataset.initialState;
16
+ if (!initialStateJson) {
17
+ return {};
18
+ }
19
+
20
+ try {
21
+ return JSON.parse(initialStateJson);
22
+ } catch (e) {
23
+ console.warn('IslandJS Turbo: Failed to parse initial state', e);
24
+ return {};
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Set up Turbo cache persistence for React component state
30
+ * @param {string} containerId - The ID of the container element
31
+ * @param {Object} currentState - Current component state to persist
32
+ * @param {boolean} autoRestore - Whether to automatically restore state on turbo:load
33
+ * @returns {Function} - Cleanup function to remove event listeners
34
+ */
35
+ export function useTurboCache(containerId, currentState, autoRestore = true) {
36
+ const container = document.getElementById(containerId);
37
+ if (!container) {
38
+ console.warn(`IslandJS Turbo: Container ${containerId} not found for caching`);
39
+ return () => {};
40
+ }
41
+
42
+ // Immediately persist the current state to the div (don't wait for turbo:before-cache)
43
+ try {
44
+ const stateJson = JSON.stringify(currentState);
45
+ container.dataset.initialState = stateJson;
46
+ } catch (e) {
47
+ console.warn('IslandJS Turbo: Failed to immediately serialize state', e);
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Hook for React components to automatically manage Turbo cache persistence
53
+ * This is a React hook that should be called from within a React component
54
+ * @param {string} containerId - The ID of the container element
55
+ * @param {Object} state - Current component state to persist
56
+ * @param {Array} dependencies - Dependencies array for useEffect
57
+ */
58
+ export function useTurboCacheEffect(containerId, state, dependencies = []) {
59
+ // This assumes React is available globally
60
+ if (typeof React !== 'undefined' && React.useEffect) {
61
+ React.useEffect(() => {
62
+ return useTurboCache(containerId, state, false);
63
+ }, [containerId, ...dependencies]);
64
+ } else {
65
+ console.warn('IslandJS Turbo: React.useEffect not available for useTurboCacheEffect');
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Manually persist state to container for components that don't use the hook
71
+ * @param {string} containerId - The ID of the container element
72
+ * @param {Object} state - State object to persist
73
+ */
74
+ export function persistState(containerId, state) {
75
+ const container = document.getElementById(containerId);
76
+ if (!container) {
77
+ console.warn(`IslandJS Turbo: Container ${containerId} not found for state persistence`);
78
+ return;
79
+ }
80
+
81
+ try {
82
+ const stateJson = JSON.stringify(state);
83
+ container.dataset.initialState = stateJson;
84
+ } catch (e) {
85
+ console.warn('IslandJS Turbo: Failed to serialize state', e);
86
+ }
87
+ }