funicular 0.1.0 → 0.2.1

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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +12 -4
  4. data/Rakefile +29 -0
  5. data/docs/architecture.md +113 -404
  6. data/lib/funicular/assets/funicular.css +23 -0
  7. data/lib/funicular/compiler.rb +25 -18
  8. data/lib/funicular/helpers/picoruby_helper.rb +65 -3
  9. data/lib/funicular/middleware.rb +34 -9
  10. data/lib/funicular/plugin.rb +147 -0
  11. data/lib/funicular/schema.rb +167 -0
  12. data/lib/funicular/ssr/runtime.rb +101 -0
  13. data/lib/funicular/ssr.rb +51 -0
  14. data/lib/funicular/testing/node_runner.mjs +293 -0
  15. data/lib/funicular/testing/node_runner.rb +190 -0
  16. data/lib/funicular/testing.rb +22 -0
  17. data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
  18. data/lib/funicular/vendor/picoruby/debug/picoruby.js +115 -87
  19. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  20. data/lib/funicular/vendor/picoruby/dist/picoruby.js +1 -1
  21. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  22. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -0
  23. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +6908 -0
  24. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  25. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -0
  26. data/lib/funicular/version.rb +1 -1
  27. data/lib/funicular.rb +3 -0
  28. data/lib/generators/funicular/chat/chat_generator.rb +104 -0
  29. data/lib/generators/funicular/chat/templates/application_cable_channel.rb.tt +4 -0
  30. data/lib/generators/funicular/chat/templates/application_cable_connection.rb.tt +4 -0
  31. data/lib/generators/funicular/chat/templates/create_funicular_chat_messages.rb.tt +10 -0
  32. data/lib/generators/funicular/chat/templates/funicular_chat.css.tt +141 -0
  33. data/lib/generators/funicular/chat/templates/funicular_chat_channel.rb.tt +5 -0
  34. data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +135 -0
  35. data/lib/generators/funicular/chat/templates/funicular_chat_component_picotest.rb.tt +64 -0
  36. data/lib/generators/funicular/chat/templates/funicular_chat_controller.rb.tt +4 -0
  37. data/lib/generators/funicular/chat/templates/funicular_chat_message.rb.tt +13 -0
  38. data/lib/generators/funicular/chat/templates/funicular_chat_messages_controller.rb.tt +23 -0
  39. data/lib/generators/funicular/chat/templates/initializer.rb.tt +4 -0
  40. data/lib/generators/funicular/chat/templates/show.html.erb.tt +6 -0
  41. data/lib/tasks/funicular.rake +87 -4
  42. data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
  43. data/minitest/fixtures/funicular_app/initializer.rb +5 -0
  44. data/minitest/form_for_test.rb +106 -0
  45. data/minitest/hydration_test.rb +87 -0
  46. data/minitest/plugin_test.rb +51 -0
  47. data/minitest/schema_test.rb +106 -0
  48. data/minitest/ssr_test.rb +94 -0
  49. data/minitest/validations_test.rb +183 -0
  50. data/mrbgem.rake +1 -0
  51. data/mrblib/0_validations.rb +206 -0
  52. data/mrblib/1_validators.rb +180 -0
  53. data/mrblib/cable.rb +24 -9
  54. data/mrblib/component.rb +246 -47
  55. data/mrblib/debug.rb +3 -0
  56. data/mrblib/differ.rb +47 -37
  57. data/mrblib/file_upload.rb +9 -1
  58. data/mrblib/form_builder.rb +28 -6
  59. data/mrblib/funicular.rb +97 -8
  60. data/mrblib/html_serializer.rb +121 -0
  61. data/mrblib/http.rb +123 -29
  62. data/mrblib/model.rb +50 -0
  63. data/mrblib/patcher.rb +78 -8
  64. data/mrblib/router.rb +40 -3
  65. data/mrblib/store.rb +304 -0
  66. data/mrblib/store_collection.rb +171 -0
  67. data/mrblib/store_singleton.rb +79 -0
  68. data/mrblib/vdom.rb +2 -0
  69. data/sig/cable.rbs +1 -0
  70. data/sig/component.rbs +13 -5
  71. data/sig/funicular.rbs +14 -1
  72. data/sig/html_serializer.rbs +20 -0
  73. data/sig/http.rbs +21 -6
  74. data/sig/model.rbs +6 -1
  75. data/sig/patcher.rbs +4 -1
  76. data/sig/router.rbs +3 -2
  77. data/sig/store.rbs +89 -0
  78. data/sig/store_collection.rbs +43 -0
  79. data/sig/store_singleton.rbs +19 -0
  80. data/sig/validations.rbs +103 -0
  81. data/sig/vdom.rbs +6 -6
  82. metadata +48 -12
  83. data/docs/README.md +0 -419
  84. data/docs/advanced-features.md +0 -632
  85. data/docs/components-and-state.md +0 -539
  86. data/docs/data-fetching.md +0 -528
  87. data/docs/forms.md +0 -446
  88. data/docs/rails-integration.md +0 -426
  89. data/docs/realtime.md +0 -543
  90. data/docs/routing-and-navigation.md +0 -427
  91. data/docs/styling.md +0 -285
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Funicular
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.1"
5
5
  end
data/lib/funicular.rb CHANGED
@@ -3,6 +3,9 @@
3
3
  require_relative "funicular/version"
4
4
  require_relative "funicular/configuration"
5
5
  require_relative "funicular/compiler"
6
+ require_relative "funicular/plugin"
7
+ require_relative "funicular/schema"
8
+ require_relative "funicular/ssr"
6
9
 
7
10
  module Funicular
8
11
  class Error < StandardError; end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module Funicular
7
+ module Generators
8
+ class ChatGenerator < Rails::Generators::Base
9
+ include Rails::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ def self.next_migration_number(dirname)
14
+ ActiveRecord::Generators::Base.next_migration_number(dirname)
15
+ end
16
+
17
+ def migration_version
18
+ "[#{ActiveRecord::Migration.current_version}]"
19
+ end
20
+
21
+ def create_rails_files
22
+ migration_template "create_funicular_chat_messages.rb.tt",
23
+ "db/migrate/create_funicular_chat_messages.rb"
24
+ template "funicular_chat_message.rb.tt",
25
+ "app/models/funicular_chat_message.rb"
26
+ template "funicular_chat_controller.rb.tt",
27
+ "app/controllers/funicular_chat_controller.rb"
28
+ template "funicular_chat_messages_controller.rb.tt",
29
+ "app/controllers/funicular_chat_messages_controller.rb"
30
+ template "application_cable_connection.rb.tt",
31
+ "app/channels/application_cable/connection.rb" unless File.exist?(Rails.root.join("app", "channels", "application_cable", "connection.rb"))
32
+ template "application_cable_channel.rb.tt",
33
+ "app/channels/application_cable/channel.rb" unless File.exist?(Rails.root.join("app", "channels", "application_cable", "channel.rb"))
34
+ template "funicular_chat_channel.rb.tt",
35
+ "app/channels/funicular_chat_channel.rb"
36
+ template "show.html.erb.tt",
37
+ "app/views/funicular_chat/show.html.erb"
38
+ template "funicular_chat.css.tt",
39
+ "app/assets/stylesheets/funicular_chat.css"
40
+ end
41
+
42
+ def create_funicular_files
43
+ template "funicular_chat_component.rb.tt",
44
+ "app/funicular/components/funicular_chat_component.rb"
45
+ template "funicular_chat_component_picotest.rb.tt",
46
+ "test/funicular/client/funicular_chat_component_picotest.rb"
47
+
48
+ @initializer_existed = File.exist?(Rails.root.join("app", "funicular", "initializer.rb"))
49
+ if @initializer_existed
50
+ say_status :skip, "app/funicular/initializer.rb already exists", :yellow
51
+ else
52
+ template "initializer.rb.tt", "app/funicular/initializer.rb"
53
+ end
54
+ end
55
+
56
+ def add_routes
57
+ routes_file = Rails.root.join("config", "routes.rb")
58
+ if File.exist?(routes_file) && File.read(routes_file).include?('get "funicular_chat"')
59
+ say_status :skip, "funicular_chat routes already exist", :yellow
60
+ return
61
+ end
62
+
63
+ route <<~ROUTES
64
+ get "funicular_chat", to: "funicular_chat#show"
65
+ get "funicular_chat/messages", to: "funicular_chat_messages#index"
66
+ post "funicular_chat/messages", to: "funicular_chat_messages#create"
67
+ ROUTES
68
+ end
69
+
70
+ def add_picoruby_include_tag
71
+ layout = Rails.root.join("app", "views", "layouts", "application.html.erb")
72
+ return unless File.exist?(layout)
73
+
74
+ content = File.read(layout)
75
+ return if content.include?("picoruby_include_tag")
76
+
77
+ if content.include?("<%= csrf_meta_tags %>")
78
+ inject_into_file layout.to_s,
79
+ " <%= picoruby_include_tag %>\n",
80
+ after: " <%= csrf_meta_tags %>\n"
81
+ else
82
+ say_status :skip, "layout does not contain csrf_meta_tags; add <%= picoruby_include_tag %> manually", :yellow
83
+ end
84
+ end
85
+
86
+ def print_next_steps
87
+ say ""
88
+ say "Funicular chat generated.", :green
89
+ say ""
90
+ say "Next steps:"
91
+ say " 1. Run `bin/rails db:migrate`"
92
+ say " 2. Run `bin/rails funicular:compile`"
93
+ say " 3. Open `/funicular_chat` in two browser windows"
94
+ say ""
95
+
96
+ if @initializer_existed
97
+ say "Add this route inside your existing Funicular.start block:"
98
+ say " router.get('/funicular_chat', to: FunicularChatComponent, as: 'funicular_chat')"
99
+ say ""
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,4 @@
1
+ module ApplicationCable
2
+ class Channel < ActionCable::Channel::Base
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module ApplicationCable
2
+ class Connection < ActionCable::Connection::Base
3
+ end
4
+ end
@@ -0,0 +1,10 @@
1
+ class CreateFunicularChatMessages < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ create_table :funicular_chat_messages do |t|
4
+ t.string :name, null: false
5
+ t.text :body, null: false
6
+
7
+ t.timestamps
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,141 @@
1
+ .funicular-chat {
2
+ min-height: 100vh;
3
+ background: #f3f4f6;
4
+ padding: 1.5rem;
5
+ }
6
+
7
+ .funicular-chat__panel {
8
+ max-width: 48rem;
9
+ margin: 0 auto;
10
+ background: #fff;
11
+ border: 1px solid #e5e7eb;
12
+ border-radius: 0.5rem;
13
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
14
+ overflow: hidden;
15
+ }
16
+
17
+ .funicular-chat__header {
18
+ padding: 1rem 1.25rem;
19
+ border-bottom: 1px solid #e5e7eb;
20
+ }
21
+
22
+ .funicular-chat__title {
23
+ margin: 0;
24
+ color: #111827;
25
+ font-size: 1.25rem;
26
+ font-weight: 700;
27
+ }
28
+
29
+ .funicular-chat__subtitle {
30
+ margin: 0.25rem 0 0;
31
+ color: #6b7280;
32
+ font-size: 0.875rem;
33
+ }
34
+
35
+ .funicular-chat__messages {
36
+ height: 24rem;
37
+ overflow-y: auto;
38
+ padding: 1.25rem;
39
+ background: #f9fafb;
40
+ }
41
+
42
+ .funicular-chat__empty {
43
+ color: #6b7280;
44
+ font-size: 0.875rem;
45
+ }
46
+
47
+ .funicular-chat__message {
48
+ display: flex;
49
+ align-items: flex-start;
50
+ gap: 0.75rem;
51
+ padding: 0.75rem;
52
+ margin-bottom: 0.75rem;
53
+ background: #fff;
54
+ border: 1px solid #e5e7eb;
55
+ border-radius: 0.375rem;
56
+ }
57
+
58
+ .funicular-chat__message-name {
59
+ flex: 0 0 auto;
60
+ min-width: 7rem;
61
+ color: #1f2937;
62
+ font-size: 0.875rem;
63
+ font-weight: 700;
64
+ }
65
+
66
+ .funicular-chat__message-body {
67
+ min-width: 0;
68
+ color: #374151;
69
+ font-size: 0.875rem;
70
+ overflow-wrap: anywhere;
71
+ }
72
+
73
+ .funicular-chat__form {
74
+ padding: 1.25rem;
75
+ border-top: 1px solid #e5e7eb;
76
+ }
77
+
78
+ .funicular-chat__form-row {
79
+ display: flex;
80
+ gap: 0.75rem;
81
+ }
82
+
83
+ .funicular-chat__input {
84
+ width: 100%;
85
+ min-width: 0;
86
+ padding: 0.5rem 0.75rem;
87
+ border: 1px solid #d1d5db;
88
+ border-radius: 0.375rem;
89
+ font: inherit;
90
+ }
91
+
92
+ .funicular-chat__input:focus {
93
+ border-color: #2563eb;
94
+ outline: 2px solid rgba(37, 99, 235, 0.25);
95
+ }
96
+
97
+ .funicular-chat__button {
98
+ flex: 0 0 auto;
99
+ padding: 0.5rem 1rem;
100
+ border: 0;
101
+ border-radius: 0.375rem;
102
+ font: inherit;
103
+ font-weight: 700;
104
+ }
105
+
106
+ .funicular-chat__button--enabled {
107
+ color: #fff;
108
+ background: #2563eb;
109
+ cursor: pointer;
110
+ }
111
+
112
+ .funicular-chat__button--enabled:hover {
113
+ background: #1d4ed8;
114
+ }
115
+
116
+ .funicular-chat__button--disabled {
117
+ color: #fff;
118
+ background: #2563eb;
119
+ cursor: not-allowed;
120
+ opacity: 0.5;
121
+ }
122
+
123
+ .funicular-chat__error {
124
+ margin-bottom: 0.75rem;
125
+ color: #b91c1c;
126
+ font-size: 0.875rem;
127
+ }
128
+
129
+ @media (max-width: 640px) {
130
+ .funicular-chat {
131
+ padding: 0.75rem;
132
+ }
133
+
134
+ .funicular-chat__form-row {
135
+ flex-direction: column;
136
+ }
137
+
138
+ .funicular-chat__message-name {
139
+ min-width: 5rem;
140
+ }
141
+ }
@@ -0,0 +1,5 @@
1
+ class FunicularChatChannel < ApplicationCable::Channel
2
+ def subscribed
3
+ stream_from "funicular_chat"
4
+ end
5
+ end
@@ -0,0 +1,135 @@
1
+ class FunicularChatComponent < Funicular::Component
2
+ styles do
3
+ shell "funicular-chat"
4
+ panel "funicular-chat__panel"
5
+ header "funicular-chat__header"
6
+ title "funicular-chat__title"
7
+ subtitle "funicular-chat__subtitle"
8
+ messages "funicular-chat__messages"
9
+ empty "funicular-chat__empty"
10
+ message "funicular-chat__message"
11
+ name "funicular-chat__message-name"
12
+ body "funicular-chat__message-body"
13
+ form "funicular-chat__form"
14
+ row "funicular-chat__form-row"
15
+ input "funicular-chat__input"
16
+ button base: "funicular-chat__button",
17
+ variants: {
18
+ enabled: "funicular-chat__button--enabled",
19
+ disabled: "funicular-chat__button--disabled"
20
+ }
21
+ error "funicular-chat__error"
22
+ end
23
+
24
+ def initialize_state
25
+ {
26
+ name: "Rails developer",
27
+ body: "",
28
+ messages: [],
29
+ error: nil,
30
+ sending: false,
31
+ connected: false
32
+ }
33
+ end
34
+
35
+ def component_mounted
36
+ load_messages
37
+ subscribe
38
+ end
39
+
40
+ def component_will_unmount
41
+ @subscription.unsubscribe if @subscription
42
+ @consumer.cleanup if @consumer
43
+ end
44
+
45
+ def load_messages
46
+ Funicular::HTTP.get("/funicular_chat/messages") do |response|
47
+ if response.ok
48
+ patch(messages: response.data)
49
+ else
50
+ patch(error: response.error_message || "Failed to load messages")
51
+ end
52
+ end
53
+ end
54
+
55
+ def subscribe
56
+ @consumer = Funicular::Cable.create_consumer("/cable")
57
+ @subscription = @consumer.subscriptions.create(channel: "FunicularChatChannel") do |message|
58
+ patch(messages: state.messages + [message])
59
+ end
60
+ @subscription.on_connected do
61
+ patch(connected: true)
62
+ end
63
+ end
64
+
65
+ def update_name(event)
66
+ patch(name: event.target[:value].to_s)
67
+ end
68
+
69
+ def update_body(event)
70
+ patch(body: event.target[:value].to_s)
71
+ end
72
+
73
+ def send_message(event = nil)
74
+ event.preventDefault if event
75
+ return if state.sending
76
+
77
+ name = state.name.to_s.strip
78
+ body = state.body.to_s.strip
79
+ if name == "" || body == ""
80
+ patch(error: "Name and message are required")
81
+ return
82
+ end
83
+
84
+ patch(sending: true, error: nil)
85
+ Funicular::HTTP.post(
86
+ "/funicular_chat/messages",
87
+ { message: { name: name, body: body } }
88
+ ) do |response|
89
+ if response.ok
90
+ patch(body: "", sending: false)
91
+ else
92
+ patch(error: response.error_message || "Failed to send message", sending: false)
93
+ end
94
+ end
95
+ end
96
+
97
+ def render
98
+ div(class: s.shell) do
99
+ div(class: s.panel) do
100
+ div(class: s.header) do
101
+ h1(class: s.title) { "Funicular Chat" }
102
+ p(class: s.subtitle) do
103
+ state.connected ? "Realtime Ruby chat over ActionCable" : "Connecting..."
104
+ end
105
+ end
106
+
107
+ div(class: s.messages) do
108
+ if state.messages.empty?
109
+ p(class: s.empty) { "No messages yet." }
110
+ else
111
+ state.messages.each do |message|
112
+ div(class: s.message, key: message["id"]) do
113
+ div(class: s.name) { message["name"] }
114
+ div(class: s.body) { message["body"] }
115
+ end
116
+ end
117
+ end
118
+ end
119
+
120
+ form(onsubmit: :send_message, class: s.form) do
121
+ if state.error
122
+ div(class: s.error) { state.error }
123
+ end
124
+ div(class: s.row) do
125
+ input(type: "text", value: state.name, placeholder: "Name", oninput: :update_name, class: s.input)
126
+ input(type: "text", value: state.body, placeholder: "Message", oninput: :update_body, class: s.input)
127
+ button(type: "submit", class: s.button(state.sending ? :disabled : :enabled), disabled: state.sending) do
128
+ state.sending ? "Sending..." : "Send"
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,64 @@
1
+ class FunicularChatComponentTest < Funicular::Testing::DOMTest
2
+ def setup
3
+ super
4
+ Funicular::HTTP.__test_messages = [
5
+ { "id" => 1, "name" => "Alice", "body" => "Hello from Rails" }
6
+ ]
7
+ end
8
+
9
+ def test_renders_loaded_messages
10
+ mount FunicularChatComponent
11
+ drain
12
+
13
+ assert_text "Funicular Chat"
14
+ assert_text "Hello from Rails"
15
+ end
16
+ end
17
+
18
+ module Funicular
19
+ module HTTP
20
+ class << self
21
+ attr_accessor :__test_messages
22
+
23
+ def get(url, &block)
24
+ block.call(Response.new(200, __test_messages))
25
+ end
26
+
27
+ def post(url, body = nil, &block)
28
+ block.call(Response.new(201, { "id" => 2, "name" => body[:message][:name], "body" => body[:message][:body] }))
29
+ end
30
+ end
31
+ end
32
+
33
+ module Cable
34
+ class TestConsumer
35
+ attr_reader :subscriptions
36
+
37
+ def initialize
38
+ @subscriptions = TestSubscriptions.new
39
+ end
40
+
41
+ def cleanup
42
+ end
43
+ end
44
+
45
+ class TestSubscriptions
46
+ def create(params, &block)
47
+ TestSubscription.new
48
+ end
49
+ end
50
+
51
+ class TestSubscription
52
+ def on_connected(&block)
53
+ block.call
54
+ end
55
+
56
+ def unsubscribe
57
+ end
58
+ end
59
+
60
+ def self.create_consumer(url)
61
+ TestConsumer.new
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,4 @@
1
+ class FunicularChatController < ApplicationController
2
+ def show
3
+ end
4
+ end
@@ -0,0 +1,13 @@
1
+ class FunicularChatMessage < ApplicationRecord
2
+ validates :name, presence: true, length: { maximum: 40 }
3
+ validates :body, presence: true, length: { maximum: 500 }
4
+
5
+ def as_json(*)
6
+ {
7
+ id: id,
8
+ name: name,
9
+ body: body,
10
+ created_at: created_at.iso8601
11
+ }
12
+ end
13
+ end
@@ -0,0 +1,23 @@
1
+ class FunicularChatMessagesController < ApplicationController
2
+ def index
3
+ messages = FunicularChatMessage.order(:created_at).last(50)
4
+ render json: messages
5
+ end
6
+
7
+ def create
8
+ message = FunicularChatMessage.new(message_params)
9
+
10
+ if message.save
11
+ ActionCable.server.broadcast("funicular_chat", message.as_json)
12
+ render json: message, status: :created
13
+ else
14
+ render json: { errors: message.errors.full_messages }, status: :unprocessable_entity
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def message_params
21
+ params.require(:message).permit(:name, :body)
22
+ end
23
+ end
@@ -0,0 +1,4 @@
1
+ Funicular.start(container: "app") do |router|
2
+ router.get("/funicular_chat", to: FunicularChatComponent, as: "funicular_chat")
3
+ router.set_default("/funicular_chat")
4
+ end
@@ -0,0 +1,6 @@
1
+ <%% content_for :head do %>
2
+ <%%= stylesheet_link_tag "funicular_chat", "data-turbo-track": "reload" %>
3
+ <%% end %>
4
+
5
+ <%%= funicular_app_container %>
6
+ <script type="application/x-mrb" src="<%%= asset_path("app.mrb") %>"></script>
@@ -4,6 +4,7 @@ namespace :funicular do
4
4
  desc "Compile Funicular Ruby files to .mrb format"
5
5
  task compile: :environment do
6
6
  require "funicular/compiler"
7
+ require "funicular/plugin"
7
8
 
8
9
  source_dir = Rails.root.join("app", "funicular")
9
10
  output_file = Rails.root.join("app", "assets", "builds", "app.mrb")
@@ -15,13 +16,20 @@ namespace :funicular do
15
16
  end
16
17
 
17
18
  begin
19
+ plugin_registry = Funicular::Plugin::Registry.new(Rails.root)
20
+ plugin_registry.validate!
21
+ plugin_registry.sync_assets
18
22
  compiler = Funicular::Compiler.new(
19
23
  source_dir: source_dir,
20
24
  output_file: output_file,
21
- debug_mode: debug_mode
25
+ debug_mode: debug_mode,
26
+ prepend_source_files: plugin_registry.local_source_files
22
27
  )
23
28
  compiler.compile
24
- rescue Funicular::Compiler::PicorbcNotFoundError => e
29
+ rescue Funicular::Plugin::Error => e
30
+ puts "ERROR: #{e.message}"
31
+ exit 1
32
+ rescue Funicular::Compiler::PicorbcMissingError => e
25
33
  puts "ERROR: #{e.message}"
26
34
  exit 1
27
35
  rescue => e
@@ -46,8 +54,8 @@ namespace :funicular do
46
54
  end
47
55
  end
48
56
 
49
- desc "Install Funicular debug assets and PicoRuby.wasm artifacts into a Rails app"
50
- task install: ["install:debug_assets", "install:wasm"] do
57
+ desc "Install Funicular debug assets, PicoRuby.wasm artifacts, and test support into a Rails app"
58
+ task install: ["install:debug_assets", "install:wasm", "install:test"] do
51
59
  puts ""
52
60
  puts "All Funicular assets installed."
53
61
  puts ""
@@ -64,6 +72,10 @@ namespace :funicular do
64
72
  puts ' <%= javascript_include_tag "funicular_debug", "data-turbo-track": "reload" %>'
65
73
  puts ' <%= stylesheet_link_tag "funicular_debug", "data-turbo-track": "reload" %>'
66
74
  puts ' <% end %>'
75
+ puts ""
76
+ puts " 4. Run `npm install` if package.json was created or updated."
77
+ puts " Client-side Funicular tests live under test/funicular/client/**/*_picotest.rb"
78
+ puts " and run through `bin/rails test`."
67
79
  end
68
80
 
69
81
  namespace :install do
@@ -126,6 +138,77 @@ namespace :funicular do
126
138
  puts "Installed PicoRuby #{variant} build to #{dst}"
127
139
  end
128
140
  end
141
+
142
+
143
+ desc "Install Funicular client test support"
144
+ task :test do
145
+ require "fileutils"
146
+ require "json"
147
+
148
+ test_dir = Rails.root.join("test")
149
+ funicular_test_dir = test_dir.join("funicular")
150
+ client_test_dir = funicular_test_dir.join("client")
151
+ FileUtils.mkdir_p(client_test_dir)
152
+
153
+ test_helper = test_dir.join("test_helper.rb")
154
+ unless File.exist?(test_helper)
155
+ File.write(test_helper, <<~TEST_HELPER)
156
+ ENV["RAILS_ENV"] ||= "test"
157
+
158
+ require_relative "../config/environment"
159
+ require "rails/test_help"
160
+ TEST_HELPER
161
+ puts "Installed #{test_helper}"
162
+ end
163
+
164
+ application_test = funicular_test_dir.join("application_test.rb")
165
+ unless File.exist?(application_test)
166
+ File.write(application_test, <<~APPLICATION_TEST)
167
+ require_relative "../test_helper"
168
+ require "funicular/testing"
169
+
170
+ class FunicularApplicationTest < ActiveSupport::TestCase
171
+ test "client-side Funicular tests" do
172
+ result = Funicular::Testing.run!(timeout_ms: 10_000)
173
+ Funicular::Testing.assert_picotests(self, result)
174
+ end
175
+ end
176
+ APPLICATION_TEST
177
+ puts "Installed #{application_test}"
178
+ end
179
+
180
+ keep_file = client_test_dir.join(".keep")
181
+ FileUtils.touch(keep_file) unless File.exist?(keep_file)
182
+
183
+ package_json = Rails.root.join("package.json")
184
+ package = if File.exist?(package_json)
185
+ JSON.parse(File.read(package_json))
186
+ else
187
+ { "private" => true }
188
+ end
189
+ package["devDependencies"] ||= {}
190
+ package["devDependencies"]["jsdom"] ||= "^26.1.0"
191
+ File.write(package_json, JSON.pretty_generate(package) + "\n")
192
+ puts "Updated #{package_json}"
193
+
194
+ gitignore = Rails.root.join(".gitignore")
195
+ if File.exist?(gitignore)
196
+ content = File.read(gitignore)
197
+ unless content.lines.any? { |line| line.chomp == "/node_modules" }
198
+ File.open(gitignore, "a") do |f|
199
+ f.puts
200
+ f.puts "# Ignore Node dependencies."
201
+ f.puts "/node_modules"
202
+ end
203
+ puts "Updated #{gitignore}"
204
+ end
205
+ end
206
+
207
+ puts "Installed Funicular test support:"
208
+ puts " - #{application_test}"
209
+ puts " - #{client_test_dir}"
210
+ puts " - jsdom dev dependency in #{package_json}"
211
+ end
129
212
  end
130
213
  end
131
214