archipelago-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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/Appraisals +16 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +79 -0
  5. data/Rakefile +17 -0
  6. data/app/controllers/archipelago/application_controller.rb +10 -0
  7. data/app/controllers/archipelago/islands_controller.rb +158 -0
  8. data/config/database.yml +5 -0
  9. data/config/routes.rb +6 -0
  10. data/lib/archipelago/action.rb +121 -0
  11. data/lib/archipelago/broadcasts.rb +17 -0
  12. data/lib/archipelago/channel.rb +36 -0
  13. data/lib/archipelago/configuration.rb +23 -0
  14. data/lib/archipelago/context.rb +14 -0
  15. data/lib/archipelago/engine.rb +21 -0
  16. data/lib/archipelago/params_dsl.rb +143 -0
  17. data/lib/archipelago/registry.rb +25 -0
  18. data/lib/archipelago/resolver.rb +54 -0
  19. data/lib/archipelago/response.rb +35 -0
  20. data/lib/archipelago/security/origin_validator.rb +32 -0
  21. data/lib/archipelago/security/redirect_validator.rb +30 -0
  22. data/lib/archipelago/test_helpers.rb +31 -0
  23. data/lib/archipelago/view_helper.rb +31 -0
  24. data/lib/archipelago-rails.rb +3 -0
  25. data/lib/archipelago.rb +71 -0
  26. data/lib/generators/archipelago/install/install_generator.rb +43 -0
  27. data/lib/generators/archipelago/install/react/react_generator.rb +380 -0
  28. data/lib/generators/archipelago/install/react/templates/entry.js.tt +13 -0
  29. data/lib/generators/archipelago/install/react/templates/generate_registry.mjs.tt +96 -0
  30. data/lib/generators/archipelago/install/react_generator.rb +3 -0
  31. data/lib/generators/archipelago/install_generator.rb +3 -0
  32. data/lib/generators/archipelago/island/island_generator.rb +44 -0
  33. data/lib/generators/archipelago/island/templates/action.rb.tt +11 -0
  34. data/lib/generators/archipelago/island/templates/component.tsx.tt +14 -0
  35. data/lib/generators/archipelago/island_generator.rb +3 -0
  36. data/test/archipelago/action_test.rb +136 -0
  37. data/test/archipelago/broadcasts_test.rb +29 -0
  38. data/test/archipelago/channel_test.rb +15 -0
  39. data/test/archipelago/notifications_test.rb +60 -0
  40. data/test/archipelago/origin_validator_test.rb +36 -0
  41. data/test/archipelago/params_dsl_test.rb +51 -0
  42. data/test/archipelago/redirect_validator_test.rb +28 -0
  43. data/test/archipelago/resolver_test.rb +80 -0
  44. data/test/archipelago/response_test.rb +30 -0
  45. data/test/archipelago/view_helper_test.rb +32 -0
  46. data/test/controllers/islands_controller_test.rb +115 -0
  47. data/test/generators/install_generator_test.rb +26 -0
  48. data/test/generators/island_generator_test.rb +20 -0
  49. data/test/generators/react_install_generator_test.rb +67 -0
  50. data/test/test_helper.rb +35 -0
  51. metadata +180 -0
@@ -0,0 +1,380 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "pathname"
5
+ require "json"
6
+
7
+ module Archipelago
8
+ module Generators
9
+ module Install
10
+ class ReactGenerator < Rails::Generators::Base
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ class_option :bundler,
14
+ type: :string,
15
+ default: "auto",
16
+ enum: %w[auto esbuild vite],
17
+ desc: "JavaScript bundler to target"
18
+
19
+ class_option :typescript,
20
+ type: :string,
21
+ default: "auto",
22
+ enum: %w[auto true false],
23
+ desc: "Generate TSX or JSX bootstrap file"
24
+
25
+ class_option :install,
26
+ type: :boolean,
27
+ default: false,
28
+ desc: "Install npm packages after generating files"
29
+
30
+ class_option :package_manager,
31
+ type: :string,
32
+ default: "auto",
33
+ enum: %w[auto yarn npm pnpm bun],
34
+ desc: "Package manager used when --install is enabled"
35
+
36
+ class_option :local_monorepo_path,
37
+ type: :string,
38
+ default: nil,
39
+ desc: "Path to local Archipelago monorepo for file: package installs"
40
+
41
+ class_option :interactive,
42
+ type: :boolean,
43
+ default: true,
44
+ desc: "Prompt for setup choices with auto-detected defaults"
45
+
46
+ class_option :auto_registry,
47
+ type: :boolean,
48
+ default: true,
49
+ desc: "For esbuild, auto-generate component registry from app/javascript/islands"
50
+
51
+ desc "Sets up React + Archipelago frontend bootstrapping for a Rails app."
52
+
53
+ def detect_stack
54
+ @detected_bundler = detect_bundler
55
+ @detected_typescript = detect_typescript?
56
+ @detected_package_manager = detect_package_manager
57
+ @detected_local_monorepo_path = detect_local_monorepo_path
58
+
59
+ @bundler = options[:bundler] == "auto" ? @detected_bundler : options[:bundler].to_sym
60
+ @use_typescript = case options[:typescript]
61
+ when "true"
62
+ true
63
+ when "false"
64
+ false
65
+ else
66
+ @detected_typescript
67
+ end
68
+ @should_install = options[:install]
69
+ @package_manager = options[:package_manager] == "auto" ? @detected_package_manager : options[:package_manager]
70
+ @local_monorepo_path = options[:local_monorepo_path] || @detected_local_monorepo_path
71
+ @auto_registry = options[:auto_registry]
72
+ end
73
+
74
+ def interactive_preferences
75
+ unless interactive_mode?
76
+ if options[:interactive]
77
+ say_status :info, "Interactive prompts skipped (non-TTY). Using detected/default options.", :yellow
78
+ end
79
+ return
80
+ end
81
+
82
+ say_status :info, "Interactive Archipelago React setup", :blue
83
+
84
+ bundler_default = @bundler == :unknown ? "esbuild" : @bundler.to_s
85
+ @bundler = ask_choice("Bundler", %w[esbuild vite], default: bundler_default).to_sym
86
+ @use_typescript = ask_yes_no("Use TypeScript for island entry files?", default: @use_typescript)
87
+ if @bundler == :esbuild
88
+ @auto_registry = ask_yes_no(
89
+ "Enable esbuild auto-registry for islands (no manual component map)?",
90
+ default: @auto_registry
91
+ )
92
+ else
93
+ @auto_registry = false
94
+ end
95
+ @should_install = ask_yes_no("Install frontend npm packages now?", default: @should_install)
96
+
97
+ if @should_install
98
+ @package_manager = ask_choice(
99
+ "Package manager",
100
+ %w[yarn npm pnpm bun],
101
+ default: @package_manager
102
+ )
103
+ end
104
+
105
+ @local_monorepo_path = prompt_for_local_monorepo_path(@local_monorepo_path)
106
+ end
107
+
108
+ def ensure_supported_stack
109
+ if @bundler == :vite
110
+ say_status :info, "Vite detected. Generating React entry; wire it into your Vite entrypoints.", :blue
111
+ return
112
+ end
113
+
114
+ return if @bundler == :esbuild
115
+
116
+ raise Thor::Error, "Could not detect JS bundler. Pass --bundler=esbuild or --bundler=vite."
117
+ end
118
+
119
+ def create_archipelago_entry
120
+ extension = @use_typescript ? "tsx" : "jsx"
121
+ @entry_relative_path = "app/javascript/archipelago/entry.#{extension}"
122
+ template "entry.js.tt", @entry_relative_path
123
+ end
124
+
125
+ def setup_esbuild_auto_registry
126
+ return unless @bundler == :esbuild
127
+ return unless @auto_registry
128
+
129
+ template "generate_registry.mjs.tt", "app/javascript/archipelago/generate_registry.mjs"
130
+ create_file registry_relative_path, initial_registry_source
131
+ wire_esbuild_package_scripts
132
+ end
133
+
134
+ def wire_esbuild_entry
135
+ return unless @bundler == :esbuild
136
+
137
+ app_entry = preferred_application_entry
138
+ if app_entry.nil?
139
+ say_status :info, "No app/javascript/application.(js|ts) file found. Import manually:", :yellow
140
+ say " import \"./archipelago/entry\""
141
+ return
142
+ end
143
+
144
+ import_line = 'import "./archipelago/entry"'
145
+ append_to_file app_entry, "\n#{import_line}\n" unless File.read(path_for(app_entry)).include?(import_line)
146
+ end
147
+
148
+ def install_packages
149
+ return unless @should_install
150
+ return unless path_exists?("package.json")
151
+
152
+ if @local_monorepo_path
153
+ run "#{resolved_package_manager} add react react-dom #{archipelago_client_package}"
154
+ run "#{resolved_package_manager} add #{archipelago_react_package}"
155
+ else
156
+ run "#{resolved_package_manager} add #{packages_for_install.join(' ')}"
157
+ end
158
+ end
159
+
160
+ def print_next_steps
161
+ say ""
162
+ say "Archipelago React frontend scaffolding created:"
163
+ say " #{@entry_relative_path}"
164
+ say ""
165
+ unless @should_install
166
+ say "Install packages:"
167
+ say " #{resolved_package_manager} add #{packages_for_install.join(' ')}"
168
+ end
169
+ say ""
170
+ if @bundler == :esbuild && @auto_registry
171
+ say "Islands in app/javascript/islands/**/* are auto-registered before esbuild runs."
172
+ say "Manual refresh command: #{script_run_command('archipelago:registry')}"
173
+ else
174
+ say "Register each island component in app/javascript/archipelago/entry.* under `registry`."
175
+ end
176
+ say "If streaming is needed, assign an ActionCable consumer to `window.Archipelago.cable`."
177
+ end
178
+
179
+ private
180
+
181
+ def detect_bundler
182
+ return :vite if gemfile_mentions?("vite_rails")
183
+ return :vite if path_exists?("vite.config.ts") || path_exists?("vite.config.js")
184
+ return :esbuild if gemfile_mentions?("jsbundling-rails")
185
+ return :esbuild if path_exists?("app/javascript/application.js") || path_exists?("app/javascript/application.ts")
186
+
187
+ :unknown
188
+ end
189
+
190
+ def detect_typescript?
191
+ return true if path_exists?("tsconfig.json")
192
+ return true if path_exists?("app/javascript/application.ts")
193
+
194
+ false
195
+ end
196
+
197
+ def detect_package_manager
198
+ return "yarn" if path_exists?("yarn.lock")
199
+ return "pnpm" if path_exists?("pnpm-lock.yaml")
200
+ return "bun" if path_exists?("bun.lockb")
201
+ return "npm" if path_exists?("package-lock.json")
202
+
203
+ "yarn"
204
+ end
205
+
206
+ def detect_local_monorepo_path
207
+ candidates = []
208
+
209
+ env_path = ENV["ARCHIPELAGO_MONOREPO_PATH"]
210
+ candidates << Pathname.new(env_path) if env_path && !env_path.strip.empty?
211
+ candidates << Pathname.new(destination_root).join("..", "cdx")
212
+ candidates << Pathname.new(destination_root).join("..", "archipelago", "cdx")
213
+ candidates << Pathname.new(__dir__).join("../../../../../../")
214
+
215
+ candidates.map(&:expand_path).uniq.find do |candidate|
216
+ local_packages_available?(candidate)
217
+ end&.to_s
218
+ end
219
+
220
+ def preferred_application_entry
221
+ return "app/javascript/application.ts" if path_exists?("app/javascript/application.ts")
222
+ return "app/javascript/application.js" if path_exists?("app/javascript/application.js")
223
+
224
+ nil
225
+ end
226
+
227
+ def gemfile_mentions?(name)
228
+ return false unless path_exists?("Gemfile")
229
+
230
+ File.read(path_for("Gemfile")).include?(name)
231
+ end
232
+
233
+ def resolved_package_manager
234
+ @package_manager
235
+ end
236
+
237
+ def packages_for_install
238
+ [
239
+ "react",
240
+ "react-dom",
241
+ archipelago_client_package,
242
+ archipelago_react_package
243
+ ]
244
+ end
245
+
246
+ def registry_relative_path
247
+ extension = @use_typescript ? "ts" : "js"
248
+ "app/javascript/archipelago/registry.generated.#{extension}"
249
+ end
250
+
251
+ def initial_registry_source
252
+ if @use_typescript
253
+ <<~TS
254
+ import type { IslandRegistry } from "@archipelago-js/react"
255
+
256
+ // Auto-generated by Archipelago. Run `#{script_run_command('archipelago:registry')}` to refresh.
257
+ const registry: IslandRegistry = {}
258
+
259
+ export default registry
260
+ TS
261
+ else
262
+ <<~JS
263
+ // Auto-generated by Archipelago. Run `#{script_run_command('archipelago:registry')}` to refresh.
264
+ const registry = {}
265
+
266
+ export default registry
267
+ JS
268
+ end
269
+ end
270
+
271
+ def wire_esbuild_package_scripts
272
+ return unless path_exists?("package.json")
273
+
274
+ package_json_path = path_for("package.json")
275
+ package_json = JSON.parse(File.read(package_json_path))
276
+ scripts = package_json["scripts"] ||= {}
277
+ registry_command = "node app/javascript/archipelago/generate_registry.mjs"
278
+
279
+ scripts["archipelago:registry"] ||= registry_command
280
+
281
+ scripts.each do |name, command|
282
+ next unless command.is_a?(String)
283
+ next unless command.include?("esbuild")
284
+ next if command.include?("archipelago:registry") || command.include?("generate_registry.mjs")
285
+
286
+ scripts[name] = "#{registry_command} && #{command}"
287
+ end
288
+
289
+ File.write(package_json_path, "#{JSON.pretty_generate(package_json)}\n")
290
+ end
291
+
292
+ def path_exists?(relative_path)
293
+ File.exist?(path_for(relative_path))
294
+ end
295
+
296
+ def path_for(relative_path)
297
+ File.expand_path(relative_path, destination_root)
298
+ end
299
+
300
+ def script_run_command(script_name)
301
+ case resolved_package_manager
302
+ when "npm"
303
+ "npm run #{script_name}"
304
+ when "pnpm"
305
+ "pnpm #{script_name}"
306
+ when "bun"
307
+ "bun run #{script_name}"
308
+ else
309
+ "yarn #{script_name}"
310
+ end
311
+ end
312
+
313
+ def interactive_mode?
314
+ options[:interactive] && $stdin.tty? && $stdout.tty?
315
+ end
316
+
317
+ def ask_choice(label, choices, default:)
318
+ answer = ask("#{label} [#{choices.join('/')}] (default: #{default})")
319
+ normalized = answer.to_s.strip
320
+ normalized = default if normalized.empty?
321
+ return normalized if choices.include?(normalized)
322
+
323
+ say_status :warning, "Invalid choice '#{normalized}', using '#{default}'.", :yellow
324
+ default
325
+ end
326
+
327
+ def ask_yes_no(question, default:)
328
+ answer = ask("#{question} [#{default ? 'Y/n' : 'y/N'}]")
329
+ normalized = answer.to_s.strip.downcase
330
+ return default if normalized.empty?
331
+ return true if %w[y yes].include?(normalized)
332
+ return false if %w[n no].include?(normalized)
333
+
334
+ say_status :warning, "Invalid answer '#{answer}', using default.", :yellow
335
+ default
336
+ end
337
+
338
+ def prompt_for_local_monorepo_path(current_path)
339
+ if current_path
340
+ use_detected = ask_yes_no(
341
+ "Use local Archipelago packages from #{current_path}?",
342
+ default: true
343
+ )
344
+ return current_path if use_detected
345
+ end
346
+
347
+ entered = ask("Local Archipelago monorepo path for file: installs (leave blank to use npm registry)")
348
+ normalized = entered.to_s.strip
349
+ return nil if normalized.empty?
350
+
351
+ root = Pathname.new(normalized).expand_path
352
+ unless local_packages_available?(root)
353
+ say_status :warning, "No packages/client and packages/react under #{root}; using npm registry.", :yellow
354
+ return nil
355
+ end
356
+
357
+ root.to_s
358
+ end
359
+
360
+ def local_packages_available?(root)
361
+ root.join("packages/client").directory? && root.join("packages/react").directory?
362
+ end
363
+
364
+ def archipelago_client_package
365
+ return "@archipelago-js/client" unless @local_monorepo_path
366
+
367
+ root = Pathname.new(@local_monorepo_path).expand_path
368
+ "@archipelago-js/client@file:#{root.join('packages/client')}"
369
+ end
370
+
371
+ def archipelago_react_package
372
+ return "@archipelago-js/react" unless @local_monorepo_path
373
+
374
+ root = Pathname.new(@local_monorepo_path).expand_path
375
+ "@archipelago-js/react@file:#{root.join('packages/react')}"
376
+ end
377
+ end
378
+ end
379
+ end
380
+ end
@@ -0,0 +1,13 @@
1
+ import { bootArchipelagoIslands } from "@archipelago-js/react"
2
+
3
+ <% if @bundler == :esbuild && @auto_registry %>
4
+ import registry from "./registry.generated"
5
+ <% else %>
6
+ // Register your islands here.
7
+ const registry = {
8
+ // Example:
9
+ // TeamMembers: TeamMembersIsland
10
+ }
11
+ <% end %>
12
+
13
+ void bootArchipelagoIslands(registry)
@@ -0,0 +1,96 @@
1
+ import fs from "node:fs"
2
+ import path from "node:path"
3
+ import { fileURLToPath } from "node:url"
4
+
5
+ const __filename = fileURLToPath(import.meta.url)
6
+ const __dirname = path.dirname(__filename)
7
+ const archipelagoDir = path.resolve(__dirname)
8
+ const islandsRoot = path.resolve(archipelagoDir, "../islands")
9
+ const outputFile = path.resolve(
10
+ archipelagoDir,
11
+ "registry.generated.<%= @use_typescript ? "ts" : "js" %>"
12
+ )
13
+
14
+ function walk(dir) {
15
+ if (!fs.existsSync(dir)) {
16
+ return []
17
+ }
18
+
19
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
20
+ const files = []
21
+
22
+ for (const entry of entries) {
23
+ const fullPath = path.join(dir, entry.name)
24
+ if (entry.isDirectory()) {
25
+ files.push(...walk(fullPath))
26
+ continue
27
+ }
28
+
29
+ if (!entry.isFile()) {
30
+ continue
31
+ }
32
+
33
+ if (!/\.(jsx?|tsx?)$/.test(entry.name)) {
34
+ continue
35
+ }
36
+
37
+ if (entry.name.endsWith(".d.ts")) {
38
+ continue
39
+ }
40
+
41
+ files.push(fullPath)
42
+ }
43
+
44
+ return files
45
+ }
46
+
47
+ function toPascalCase(value) {
48
+ return value
49
+ .split(/[_-]/)
50
+ .filter(Boolean)
51
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
52
+ .join("")
53
+ }
54
+
55
+ function componentNameFromRelative(relativeWithoutExt) {
56
+ const parts = relativeWithoutExt.split(path.sep).filter(Boolean)
57
+ return parts.map(toPascalCase).join("__")
58
+ }
59
+
60
+ const islandFiles = walk(islandsRoot).sort()
61
+
62
+ const imports = []
63
+ const registryRows = []
64
+
65
+ islandFiles.forEach((absolutePath, index) => {
66
+ const relativeFromIslands = path.relative(islandsRoot, absolutePath)
67
+ const relativeWithoutExt = relativeFromIslands.replace(/\.[^.]+$/, "")
68
+ const importPath = `../islands/${relativeWithoutExt.split(path.sep).join("/")}`
69
+ const componentName = componentNameFromRelative(relativeWithoutExt)
70
+ const importName = `Island${index + 1}`
71
+
72
+ imports.push(`import ${importName} from "${importPath}"`)
73
+ registryRows.push(` "${componentName}": ${importName}`)
74
+ })
75
+
76
+ const source = [
77
+ "// This file is auto-generated by app/javascript/archipelago/generate_registry.mjs.",
78
+ "// Do not edit manually.",
79
+ "",
80
+ ...imports,
81
+ imports.length > 0 ? "" : "",
82
+ <% if @use_typescript %>
83
+ "import type { IslandRegistry } from \"@archipelago-js/react\"",
84
+ "",
85
+ "const registry: IslandRegistry = {",
86
+ <% else %>
87
+ "const registry = {",
88
+ <% end %>
89
+ ...registryRows,
90
+ "}",
91
+ "",
92
+ "export default registry",
93
+ ""
94
+ ].join("\n")
95
+
96
+ fs.writeFileSync(outputFile, source, "utf8")
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "react/react_generator"
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "install/install_generator"
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/named_base"
5
+
6
+ module Archipelago
7
+ module Generators
8
+ class IslandGenerator < Rails::Generators::NamedBase
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ argument :operations, type: :array, default: [], banner: "operation operation"
12
+
13
+ def ensure_operations
14
+ raise ArgumentError, "At least one operation is required" if operations.empty?
15
+ end
16
+
17
+ def create_action_files
18
+ operations.each do |operation|
19
+ @operation = operation
20
+ template "action.rb.tt", File.join("app/islands", file_path, "#{operation}.rb")
21
+ end
22
+ end
23
+
24
+ def create_component_file
25
+ @component_name = class_name
26
+ template "component.tsx.tt", File.join("app/javascript/islands", "#{class_name}.tsx")
27
+ end
28
+
29
+ private
30
+
31
+ def component_module_parts
32
+ class_name.split("::")
33
+ end
34
+
35
+ def component_module
36
+ component_module_parts.join("::")
37
+ end
38
+
39
+ def operation_class
40
+ @operation.camelize
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,11 @@
1
+ module Islands
2
+ module <%= component_module %>
3
+ class <%= operation_class %> < Archipelago::Action
4
+ authorize { true }
5
+
6
+ def perform
7
+ props message: "Implement <%= operation_class %>"
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,14 @@
1
+ import React from "react"
2
+ import { useIslandForm, useIslandProps } from "@archipelago-js/react"
3
+
4
+ export default function <%= @component_name %>() {
5
+ const { props } = useIslandProps()
6
+ const form = useIslandForm({ initialData: {} })
7
+
8
+ return (
9
+ <div>
10
+ <pre>{JSON.stringify(props, null, 2)}</pre>
11
+ <button onClick={() => form.post("<%= operations.first %>")}>Run <%= operations.first %></button>
12
+ </div>
13
+ )
14
+ }
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "island/island_generator"