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,394 @@
1
+ module IslandjsRails
2
+ module RailsHelpers
3
+ # Main helper method that combines all IslandJS functionality
4
+ def islands
5
+ output = []
6
+ output << island_partials # Now uses vendor UMD partial
7
+ output << island_bundle_script
8
+ output << umd_versions_debug if Rails.env.development?
9
+ output.compact.join("\n").html_safe
10
+ end
11
+
12
+ # Render all island partials (CDN scripts for external libraries)
13
+ # Now delegates to the vendor UMD partial for better performance
14
+ def island_partials
15
+ render(partial: "shared/islands/vendor_umd").html_safe
16
+ rescue ActionView::MissingTemplate
17
+ if Rails.env.development?
18
+ "<!-- IslandJS: Vendor UMD partial missing. Run: rails islandjs:init -->".html_safe
19
+ else
20
+ "".html_safe
21
+ end
22
+ end
23
+
24
+ # Render the main IslandJS bundle script tag
25
+ def island_bundle_script
26
+ manifest_path = Rails.root.join('public', 'islands_manifest.json')
27
+ bundle_path = '/islands_bundle.js'
28
+
29
+ unless File.exist?(manifest_path)
30
+ # Fallback to direct bundle path when no manifest
31
+ return html_safe_string("<script src=\"#{bundle_path}\" defer></script>")
32
+ end
33
+
34
+ begin
35
+ manifest = JSON.parse(File.read(manifest_path))
36
+ # Look for islands_bundle.js in manifest
37
+ bundle_file = manifest['islands_bundle.js']
38
+
39
+ if bundle_file
40
+ html_safe_string("<script src=\"#{bundle_file}\" defer></script>")
41
+ else
42
+ # Fallback to direct bundle path
43
+ html_safe_string("<script src=\"#{bundle_path}\" defer></script>")
44
+ end
45
+ rescue JSON::ParserError
46
+ # Fallback to direct bundle path on manifest parse error
47
+ html_safe_string("<script src=\"#{bundle_path}\" defer></script>")
48
+ end
49
+ end
50
+
51
+ # Mount a React component with props and Turbo-compatible lifecycle
52
+ def react_component(component_name, props = {}, options = {})
53
+ # Generate component ID - use custom container_id if provided
54
+ if options[:container_id]
55
+ component_id = options[:container_id]
56
+ else
57
+ component_id = "react-#{component_name.gsub(/([A-Z])/, '-\1').downcase.gsub(/^-/, '')}-#{SecureRandom.hex(4)}"
58
+ end
59
+
60
+ # Extract options
61
+ tag_name = options[:tag] || 'div'
62
+ css_class = options[:class] || ''
63
+ namespace = options[:namespace] || 'window.islandjsRails'
64
+
65
+ # For turbo-cache compatibility, store initial state as JSON in data attribute
66
+ initial_state_json = props.to_json
67
+
68
+ # Generate data attributes from props with proper HTML escaping (keeping for backward compatibility)
69
+ data_attrs = props.map do |key, value|
70
+ # Convert both camelCase and snake_case to kebab-case
71
+ attr_name = key.to_s.gsub(/([A-Z])/, '-\1').gsub('_', '-').downcase.gsub(/^-/, '')
72
+ # Properly escape HTML entities
73
+ attr_value = if value.nil?
74
+ ''
75
+ else
76
+ value.to_s.gsub('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;').gsub('"', '&quot;')
77
+ end
78
+ "data-#{attr_name}=\"#{attr_value}\""
79
+ end.join(' ')
80
+
81
+ # Generate optional chaining syntax for custom namespaces
82
+ namespace_with_optional = if namespace != 'window.islandjsRails' && !namespace.include?('?')
83
+ namespace + '?'
84
+ else
85
+ namespace
86
+ end
87
+
88
+ # Generate the mounting script - pass container_id as the only prop for turbo-cache pattern
89
+ mount_script = generate_react_mount_script(component_name, component_id, namespace, namespace_with_optional)
90
+
91
+ # Return the container div with data-initial-state and script
92
+ data_part = data_attrs.empty? ? '' : " #{data_attrs}"
93
+ class_part = css_class.empty? ? '' : " class=\"#{css_class}\""
94
+
95
+ # Add data-initial-state for turbo-cache compatibility
96
+ initial_state_attr = " data-initial-state=\"#{initial_state_json.gsub('"', '&quot;')}\""
97
+
98
+ container_html = "<#{tag_name} id=\"#{component_id}\"#{class_part}#{data_part}#{initial_state_attr}></#{tag_name}>"
99
+
100
+ html_safe_string("#{container_html}\n#{mount_script}")
101
+ end
102
+
103
+ # Mount a Vue component with props and Turbo-compatible lifecycle
104
+ def vue_component(component_name, props = {}, options = {})
105
+ # Generate unique ID for this component instance
106
+ component_id = "vue-#{component_name.downcase}-#{SecureRandom.hex(4)}"
107
+
108
+ # Prepare props as JSON
109
+ props_json = props.to_json
110
+
111
+ # Extract options
112
+ tag_name = options[:tag] || 'div'
113
+ css_class = options[:class] || ''
114
+
115
+ # Generate the mounting script
116
+ mount_script = generate_vue_mount_script(component_name, component_id, props_json)
117
+
118
+ # Return the container div and script
119
+ container_html = "<#{tag_name} id=\"#{component_id}\" class=\"#{css_class}\"></#{tag_name}>"
120
+
121
+ html_safe_string("#{container_html}\n#{mount_script}")
122
+ end
123
+
124
+ # Generic island component helper
125
+ def island_component(framework, component_name, props = {}, options = {})
126
+ case framework.to_s.downcase
127
+ when 'react'
128
+ react_component(component_name, props, options)
129
+ when 'vue'
130
+ vue_component(component_name, props, options)
131
+ else
132
+ html_safe_string("<!-- Unsupported framework: #{framework} -->")
133
+ end
134
+ end
135
+
136
+ # Debug helper to show available components
137
+ def island_debug
138
+ return '' unless Rails.env.development?
139
+
140
+ debug_info = {
141
+ bundle_path: find_bundle_path,
142
+ partials_count: Dir.glob(File.join(IslandjsRails.configuration.partials_dir, '*.html.erb')).count,
143
+ webpack_config_exists: File.exist?(IslandjsRails.configuration.webpack_config_path),
144
+ package_json_exists: File.exist?(IslandjsRails.configuration.package_json_path)
145
+ }
146
+
147
+ debug_html = <<~HTML
148
+ <div style="background: #f0f0f0; padding: 10px; margin: 10px 0; border: 1px solid #ccc; font-family: monospace; font-size: 12px;">
149
+ <strong>🏝️ IslandJS Debug Info:</strong><br>
150
+ Bundle Path: #{debug_info[:bundle_path] || 'Not found'}<br>
151
+ Partials: #{debug_info[:partials_count]} found<br>
152
+ Webpack Config: #{debug_info[:webpack_config_exists] ? '✓' : '✗'}<br>
153
+ Package.json: #{debug_info[:package_json_exists] ? '✓' : '✗'}
154
+ </div>
155
+ HTML
156
+
157
+ html_safe_string(debug_html)
158
+ end
159
+
160
+ # Legacy UMD helper methods for backward compatibility with tests
161
+ def umd_versions_debug
162
+ return unless Rails.env.development?
163
+
164
+ begin
165
+ installed = IslandjsRails.core.send(:installed_packages)
166
+ supported = installed.select { |pkg| IslandjsRails.core.send(:supported_package?, pkg) }
167
+
168
+ if supported.empty?
169
+ return %(<div style="position: fixed; bottom: 10px; right: 10px; background: #666; color: #fff; padding: 5px; font-size: 10px; z-index: 9999;">UMD: No packages</div>).html_safe
170
+ end
171
+
172
+ versions = supported.map do |package_name|
173
+ begin
174
+ version = IslandjsRails.version_for(package_name)
175
+ "#{package_name}: #{version}"
176
+ rescue
177
+ "#{package_name}: error"
178
+ end
179
+ end.join(', ')
180
+
181
+ %(<div style="position: fixed; bottom: 10px; right: 10px; background: #000; color: #fff; padding: 5px; font-size: 10px; z-index: 9999;">UMD: #{versions}</div>).html_safe
182
+ rescue => e
183
+ %(<div style="position: fixed; bottom: 10px; right: 10px; background: #f00; color: #fff; padding: 5px; font-size: 10px; z-index: 9999;">UMD Error: #{e.message}</div>).html_safe
184
+ end
185
+ end
186
+
187
+ def umd_partial_for(package_name)
188
+ # Backward compatibility: delegate to vendor UMD partial
189
+ # Individual package partials are no longer used
190
+ if Rails.env.development?
191
+ "<!-- IslandJS: umd_partial_for('#{package_name}') is deprecated. Use island_partials or render 'shared/islands/vendor_umd' instead -->".html_safe
192
+ else
193
+ # In production, silently delegate to vendor partial
194
+ render(partial: "shared/islands/vendor_umd").html_safe
195
+ end
196
+ rescue ActionView::MissingTemplate
197
+ if Rails.env.development?
198
+ "<!-- IslandJS: Vendor UMD partial missing. Run: rails islandjs:init -->".html_safe
199
+ else
200
+ "".html_safe
201
+ end
202
+ end
203
+
204
+ def react_partials
205
+ packages = ['react', 'react-dom']
206
+ partials = packages.map { |pkg| umd_partial_for(pkg) }.compact.join("\n")
207
+ html_safe_string(partials)
208
+ end
209
+
210
+ private
211
+
212
+ # Find the bundle file path (with manifest support)
213
+ def find_bundle_path
214
+ # Try manifest first (production)
215
+ manifest_path = Rails.root.join('public', 'islands_manifest.json')
216
+
217
+ if File.exist?(manifest_path)
218
+ begin
219
+ manifest = JSON.parse(File.read(manifest_path))
220
+ # Look for islands_bundle in manifest
221
+ bundle_key = manifest.keys.find { |key| key.include?('islands_bundle') }
222
+ return "/#{manifest[bundle_key]}" if bundle_key && manifest[bundle_key]
223
+ rescue JSON::ParserError
224
+ # Fall through to direct file check
225
+ end
226
+ end
227
+
228
+ # Try direct file (development)
229
+ direct_bundle_path = Rails.root.join('public', 'islands_bundle.js')
230
+ if File.exist?(direct_bundle_path)
231
+ return '/islands_bundle.js'
232
+ end
233
+
234
+ # Bundle not found
235
+ nil
236
+ end
237
+
238
+ # Generate React component mounting script with Turbo compatibility
239
+ def generate_react_mount_script(component_name, component_id, namespace, namespace_with_optional)
240
+ <<~JAVASCRIPT
241
+ <script>
242
+ (function() {
243
+ function mount#{component_name}() {
244
+ if (typeof #{namespace_with_optional} === 'undefined' || !#{namespace_with_optional}.#{component_name}) {
245
+ console.warn('IslandJS: #{component_name} component not found. Make sure it\\'s exported in your bundle.');
246
+ return;
247
+ }
248
+
249
+ if (typeof React === 'undefined' || typeof window.ReactDOM === 'undefined') {
250
+ console.warn('IslandJS: React or ReactDOM not loaded. Install with: rails "islandjs:install[react]" and rails "islandjs:install[react-dom]"');
251
+ return;
252
+ }
253
+
254
+ const container = document.getElementById('#{component_id}');
255
+ if (!container) return;
256
+
257
+ const props = { containerId: '#{component_id}' };
258
+ const element = React.createElement(#{namespace_with_optional}.#{component_name}, props);
259
+
260
+ // Use React 18 createRoot if available, fallback to React 17 render
261
+ if (window.ReactDOM.createRoot) {
262
+ if (!container._reactRoot) {
263
+ container._reactRoot = window.ReactDOM.createRoot(container);
264
+ }
265
+ container._reactRoot.render(element);
266
+ } else {
267
+ window.ReactDOM.render(element, container);
268
+ }
269
+ }
270
+
271
+ function cleanup#{component_name}() {
272
+ const container = document.getElementById('#{component_id}');
273
+ if (!container) return;
274
+
275
+ // React 18 unmount
276
+ if (container._reactRoot) {
277
+ container._reactRoot.unmount();
278
+ container._reactRoot = null;
279
+ } else if (typeof window.ReactDOM !== 'undefined' && window.ReactDOM.unmountComponentAtNode) {
280
+ // React 17 unmount
281
+ window.ReactDOM.unmountComponentAtNode(container);
282
+ }
283
+ }
284
+
285
+ // Mount on page load and Turbo navigation
286
+ if (document.readyState === 'loading') {
287
+ document.addEventListener('DOMContentLoaded', mount#{component_name});
288
+ } else {
289
+ mount#{component_name}();
290
+ }
291
+
292
+ // Turbo compatibility
293
+ document.addEventListener('turbo:load', mount#{component_name});
294
+ document.addEventListener('turbo:before-cache', cleanup#{component_name});
295
+ document.addEventListener('turbo:render', mount#{component_name});
296
+ document.addEventListener('turbo:before-render', cleanup#{component_name});
297
+
298
+ // Legacy Turbolinks compatibility
299
+ document.addEventListener('turbolinks:load', mount#{component_name});
300
+ document.addEventListener('turbolinks:before-cache', cleanup#{component_name});
301
+ })();
302
+ </script>
303
+ JAVASCRIPT
304
+ end
305
+
306
+ # Generate Vue component mounting script with Turbo compatibility
307
+ def generate_vue_mount_script(component_name, component_id, props_json)
308
+ <<~JAVASCRIPT
309
+ <script>
310
+ (function() {
311
+ let vueApp = null;
312
+
313
+ function mount#{component_name}() {
314
+ if (typeof window.islandjsRails === 'undefined' || !window.islandjsRails.#{component_name}) {
315
+ console.warn('IslandJS: #{component_name} component not found. Make sure it\\'s exported in your bundle.');
316
+ return;
317
+ }
318
+
319
+ if (typeof Vue === 'undefined') {
320
+ console.warn('IslandJS: Vue not loaded. Install with: rails "islandjs:install[vue]"');
321
+ return;
322
+ }
323
+
324
+ const container = document.getElementById('#{component_id}');
325
+ if (!container) return;
326
+
327
+ const props = #{props_json};
328
+
329
+ // Vue 3 syntax
330
+ if (Vue.createApp) {
331
+ vueApp = Vue.createApp({
332
+ render() {
333
+ return Vue.h(window.islandjsRails.#{component_name}, props);
334
+ }
335
+ });
336
+ vueApp.mount('##{component_id}');
337
+ } else {
338
+ // Vue 2 syntax
339
+ vueApp = new Vue({
340
+ el: '##{component_id}',
341
+ render: function(h) {
342
+ return h(window.islandjsRails.#{component_name}, { props: props });
343
+ }
344
+ });
345
+ }
346
+ }
347
+
348
+ function unmount#{component_name}() {
349
+ if (vueApp) {
350
+ if (vueApp.unmount) {
351
+ // Vue 3
352
+ vueApp.unmount();
353
+ } else if (vueApp.$destroy) {
354
+ // Vue 2
355
+ vueApp.$destroy();
356
+ }
357
+ vueApp = null;
358
+ }
359
+ }
360
+
361
+ // Mount on page load and Turbo navigation
362
+ if (document.readyState === 'loading') {
363
+ document.addEventListener('DOMContentLoaded', mount#{component_name});
364
+ } else {
365
+ mount#{component_name}();
366
+ }
367
+
368
+ // Turbo compatibility
369
+ document.addEventListener('turbo:load', mount#{component_name});
370
+ document.addEventListener('turbo:before-cache', unmount#{component_name});
371
+
372
+ // Legacy Turbolinks compatibility
373
+ document.addEventListener('turbolinks:load', mount#{component_name});
374
+ document.addEventListener('turbolinks:before-cache', unmount#{component_name});
375
+ })();
376
+ </script>
377
+ JAVASCRIPT
378
+ end
379
+
380
+ # Cross-Rails version html_safe compatibility
381
+ def html_safe_string(string)
382
+ if string.respond_to?(:html_safe)
383
+ string.html_safe
384
+ else
385
+ string
386
+ end
387
+ end
388
+ end
389
+ end
390
+
391
+ # Auto-include in ActionView if Rails is present
392
+ if defined?(ActionView::Base)
393
+ ActionView::Base.include IslandjsRails::RailsHelpers
394
+ end
@@ -0,0 +1,59 @@
1
+ require 'rails/railtie'
2
+
3
+ module IslandjsRails
4
+ class Railtie < Rails::Railtie
5
+ railtie_name :islandjs_rails
6
+
7
+ rake_tasks do
8
+ load File.expand_path('tasks.rb', __dir__)
9
+ end
10
+
11
+ initializer 'islandjs_rails.helpers' do
12
+ ActiveSupport.on_load(:action_view) do
13
+ include IslandjsRails::RailsHelpers
14
+ end
15
+ end
16
+
17
+ # Development-only warnings and checks
18
+ initializer 'islandjs_rails.development_warnings', after: :load_config_initializers do
19
+ if Rails.env.development?
20
+ # Check for common setup issues
21
+ Rails.application.config.after_initialize do
22
+ check_development_setup
23
+ end
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def check_development_setup
30
+ # Check if package.json exists
31
+ unless File.exist?(Rails.root.join('package.json'))
32
+ Rails.logger.warn "IslandJS: package.json not found. Run 'rails islandjs:init' to set up."
33
+ return
34
+ end
35
+
36
+ # Check if webpack config exists
37
+ unless File.exist?(Rails.root.join('webpack.config.js'))
38
+ Rails.logger.warn "IslandJS: webpack.config.js not found. Run 'rails islandjs:init' to set up."
39
+ return
40
+ end
41
+
42
+ # Check if yarn is available
43
+ unless system('which yarn > /dev/null 2>&1')
44
+ Rails.logger.warn "IslandJS: yarn not found. Please install yarn for package management."
45
+ return
46
+ end
47
+
48
+ # Check if essential webpack dependencies are installed
49
+ essential_deps = ['webpack', 'webpack-cli', '@babel/core']
50
+ missing_deps = essential_deps.select do |dep|
51
+ !system("yarn list #{dep} > /dev/null 2>&1")
52
+ end
53
+
54
+ unless missing_deps.empty?
55
+ Rails.logger.warn "IslandJS: Missing dependencies: #{missing_deps.join(', ')}. Run 'rails islandjs:init' to install."
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,118 @@
1
+ require 'rake'
2
+
3
+ namespace :islandjs do
4
+ desc "Initialize IslandJS in this Rails project"
5
+ task :init => :environment do
6
+ IslandjsRails.init!
7
+ end
8
+
9
+ desc "Install a JavaScript island package"
10
+ task :install, [:package_name, :version] => :environment do |t, args|
11
+ package_name = args[:package_name]
12
+ version = args[:version]
13
+
14
+ if package_name.nil?
15
+ puts "❌ Package name is required"
16
+ puts "Usage: rails \"islandjs:install[react,18.3.1]\""
17
+ exit 1
18
+ end
19
+
20
+ IslandjsRails.install!(package_name, version)
21
+ end
22
+
23
+ desc "Update a JavaScript island package"
24
+ task :update, [:package_name, :version] => :environment do |t, args|
25
+ package_name = args[:package_name]
26
+ version = args[:version]
27
+
28
+ if package_name.nil?
29
+ puts "❌ Package name is required"
30
+ puts "Usage: rails \"islandjs:update[react,18.3.1]\""
31
+ exit 1
32
+ end
33
+
34
+ IslandjsRails.update!(package_name, version)
35
+ end
36
+
37
+ desc "Remove a JavaScript island package"
38
+ task :remove, [:package_name] => :environment do |t, args|
39
+ package_name = args[:package_name]
40
+
41
+ if package_name.nil?
42
+ puts "❌ Package name is required"
43
+ puts "Usage: rails \"islandjs:remove[react]\""
44
+ exit 1
45
+ end
46
+
47
+ IslandjsRails.remove!(package_name)
48
+ end
49
+
50
+ desc "Sync all JavaScript island packages with current package.json"
51
+ task :sync => :environment do
52
+ IslandjsRails.sync!
53
+ end
54
+
55
+ desc "Show status of all JavaScript island packages"
56
+ task :status => :environment do
57
+ IslandjsRails.status!
58
+ end
59
+
60
+ desc "Clean all island partials and reset webpack externals"
61
+ task :clean => :environment do
62
+ IslandjsRails.clean!
63
+ end
64
+
65
+ desc "Show IslandJS configuration"
66
+ task :config => :environment do
67
+ config = IslandjsRails.configuration
68
+ puts "📊 IslandjsRails Configuration"
69
+ puts "=" * 40
70
+ puts "Package.json path: #{config.package_json_path}"
71
+ puts "Partials directory: #{config.partials_dir}"
72
+ puts "Webpack config path: #{config.webpack_config_path}"
73
+ puts "Supported CDNs: #{config.supported_cdns.join(', ')}"
74
+ puts "Built-in global name overrides: #{IslandjsRails::BUILT_IN_GLOBAL_NAME_OVERRIDES.size} available"
75
+ end
76
+
77
+ desc "Show IslandJS version"
78
+ task :version do
79
+ puts "IslandjsRails #{IslandjsRails::VERSION}"
80
+ end
81
+
82
+ namespace :vendor do
83
+ desc "Rebuild combined vendor bundle (for :external_combined mode)"
84
+ task :rebuild_combined => :environment do
85
+ IslandjsRails.vendor_manager.rebuild_combined_bundle!
86
+ end
87
+
88
+ desc "Show vendor configuration and status"
89
+ task :status => :environment do
90
+ config = IslandjsRails.configuration
91
+ puts "📦 IslandJS Vendor Status"
92
+ puts "=" * 40
93
+ puts "Mode: #{config.vendor_script_mode}"
94
+ puts "Vendor directory: #{config.vendor_dir}"
95
+ puts "Combined basename: #{config.combined_basename}"
96
+ puts "Vendor order: #{config.vendor_order.join(', ')}"
97
+
98
+ # Show manifest info
99
+ manifest_path = config.vendor_manifest_path
100
+ if File.exist?(manifest_path)
101
+ require 'json'
102
+ manifest = JSON.parse(File.read(manifest_path))
103
+ puts "\nInstalled libraries: #{manifest['libs'].length}"
104
+ manifest['libs'].each do |lib|
105
+ puts " • #{lib['name']}@#{lib['version']} (#{lib['file']})"
106
+ end
107
+
108
+ if manifest['combined']
109
+ puts "\nCombined bundle: #{manifest['combined']['file']} (#{manifest['combined']['size_kb']}KB)"
110
+ end
111
+ else
112
+ puts "\nNo vendor manifest found"
113
+ end
114
+ end
115
+ end
116
+ end
117
+
118
+