funicular 0.3.0 → 0.5.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +486 -1
  3. data/demo/local_notes.html +207 -0
  4. data/demo/test_chartjs.html +9 -9
  5. data/demo/test_component.html +8 -8
  6. data/demo/test_error_boundary.html +44 -41
  7. data/demo/test_router.html +48 -48
  8. data/demo/tic-tac-toe.html +25 -25
  9. data/docs/architecture.md +227 -12
  10. data/docs/local_database.md +1035 -0
  11. data/lib/funicular/assets/funicular.rb +14 -0
  12. data/lib/funicular/configuration.rb +65 -0
  13. data/lib/funicular/epoch_header.rb +69 -0
  14. data/lib/funicular/epoch_stamping.rb +66 -0
  15. data/lib/funicular/helpers/picoruby_helper.rb +96 -1
  16. data/lib/funicular/railtie.rb +30 -0
  17. data/lib/funicular/schema.rb +45 -12
  18. data/lib/funicular/session_epoch.rb +110 -0
  19. data/lib/funicular/ssr/runtime.rb +58 -12
  20. data/lib/funicular/ssr.rb +25 -0
  21. data/lib/funicular/testing/node_runner.mjs +19 -0
  22. data/lib/funicular/testing.rb +47 -0
  23. data/lib/funicular/vendor/mrbc/VERSION +1 -1
  24. data/lib/funicular/vendor/mrbc/mrbc.js +655 -574
  25. data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
  26. data/lib/funicular/vendor/picoruby/VERSION +1 -1
  27. data/lib/funicular/vendor/picoruby/debug/picoruby.js +800 -530
  28. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  29. data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -2
  30. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  31. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -1
  32. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +2 -6909
  33. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  34. data/lib/funicular/version.rb +1 -1
  35. data/lib/funicular.rb +1 -0
  36. data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +37 -38
  37. data/lib/tasks/funicular.rake +10 -2
  38. data/minitest/callback_error_visibility_test.rb +48 -0
  39. data/minitest/configuration_test.rb +78 -0
  40. data/minitest/dsl_test.rb +264 -0
  41. data/minitest/epoch_header_test.rb +149 -0
  42. data/minitest/epoch_stamping_test.rb +225 -0
  43. data/minitest/fixtures/funicular_app/components/greeting_component.rb +5 -5
  44. data/minitest/fixtures/funicular_app/components/probe_component.rb +15 -0
  45. data/minitest/form_for_test.rb +2 -2
  46. data/minitest/hydration_test.rb +2 -2
  47. data/minitest/navigation_guard_test.rb +65 -0
  48. data/minitest/picoruby_helper_test.rb +236 -0
  49. data/minitest/schema_test.rb +47 -0
  50. data/minitest/session_epoch_test.rb +122 -0
  51. data/minitest/sig_tags_test.rb +30 -0
  52. data/minitest/ssr_database_test.rb +78 -0
  53. data/minitest/ssr_reload_test.rb +106 -0
  54. data/minitest/ssr_test.rb +41 -0
  55. data/minitest/testing_ensure_compiled_test.rb +52 -0
  56. data/minitest/validations_test.rb +35 -5
  57. data/minitest/view_context_test.rb +15 -15
  58. data/mrbgem.rake +2 -0
  59. data/mrblib/0_tags.rb +62 -0
  60. data/mrblib/cable.rb +1 -1
  61. data/mrblib/component.rb +226 -24
  62. data/mrblib/db.rb +3116 -0
  63. data/mrblib/error_boundary.rb +25 -19
  64. data/mrblib/file_upload.rb +17 -7
  65. data/mrblib/form_builder.rb +10 -10
  66. data/mrblib/funicular.rb +136 -17
  67. data/mrblib/http.rb +84 -107
  68. data/mrblib/model.rb +1178 -23
  69. data/mrblib/relation.rb +342 -0
  70. data/mrblib/router.rb +45 -4
  71. data/mrblib/styles.rb +122 -12
  72. data/mrblib/view_context.rb +3 -32
  73. data/sig/component.rbs +25 -4
  74. data/sig/db.rbs +328 -0
  75. data/sig/error_boundary.rbs +4 -4
  76. data/sig/funicular.rbs +5 -0
  77. data/sig/http.rbs +8 -21
  78. data/sig/model.rbs +101 -7
  79. data/sig/relation.rbs +44 -0
  80. data/sig/router.rbs +1 -0
  81. data/sig/styles.rbs +19 -5
  82. data/sig/tags.rbs +54 -0
  83. data/sig/view_context.rbs +47 -34
  84. metadata +23 -2
  85. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +0 -1
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # Exercises Funicular::SessionEpoch, the server half of the session
6
+ # epoch (docs decision 13): identity encoding, rotation on every
7
+ # authentication transition, stability while the identity holds, and
8
+ # per-application_id isolation inside one shared Rails session.
9
+ class SessionEpochTest < Minitest::Test
10
+ FakeController = Struct.new(:current_user_key)
11
+
12
+ def setup
13
+ @config = Funicular::Configuration.new
14
+ Funicular.instance_variable_set(:@configuration, @config)
15
+ @session = {}
16
+ end
17
+
18
+ def teardown
19
+ Funicular.instance_variable_set(:@configuration, nil)
20
+ end
21
+
22
+ def controller_for(key)
23
+ FakeController.new(key)
24
+ end
25
+
26
+ def configure_user_key
27
+ @config.user_key = ->(controller) { controller&.current_user_key }
28
+ end
29
+
30
+ def stamp(key)
31
+ Funicular::SessionEpoch.stamp!(@session, controller_for(key))
32
+ end
33
+
34
+ # --- identity ---------------------------------------------------------
35
+
36
+ def test_identity_is_the_typed_versioned_tuple
37
+ configure_user_key
38
+ assert_equal '["v1","funicular","anonymous"]',
39
+ Funicular::SessionEpoch.identity(controller_for(nil))
40
+ assert_equal '["v1","funicular","user","u1"]',
41
+ Funicular::SessionEpoch.identity(controller_for("u1"))
42
+ end
43
+
44
+ def test_user_key_is_canonicalized_with_to_s
45
+ configure_user_key
46
+ assert_equal '["v1","funicular","user","42"]',
47
+ Funicular::SessionEpoch.identity(controller_for(42))
48
+ end
49
+
50
+ def test_a_user_key_of_anonymous_cannot_collide_with_signed_out
51
+ configure_user_key
52
+ refute_equal Funicular::SessionEpoch.identity(controller_for(nil)),
53
+ Funicular::SessionEpoch.identity(controller_for("anonymous"))
54
+ end
55
+
56
+ def test_without_a_user_key_lambda_everyone_is_anonymous
57
+ assert_equal '["v1","funicular","anonymous"]',
58
+ Funicular::SessionEpoch.identity(controller_for("u1"))
59
+ end
60
+
61
+ def test_empty_resolved_user_key_fails_loud
62
+ configure_user_key
63
+ error = assert_raises(Funicular::Error) { stamp("") }
64
+ assert_includes error.message, "empty"
65
+ end
66
+
67
+ # --- rotation ---------------------------------------------------------
68
+
69
+ def test_stamp_creates_and_then_holds_an_epoch
70
+ configure_user_key
71
+ epoch = stamp("u1")
72
+ refute_empty epoch
73
+ assert_equal epoch, stamp("u1")
74
+ assert_equal epoch, stamp("u1")
75
+ end
76
+
77
+ def test_login_logout_and_switch_all_rotate
78
+ configure_user_key
79
+ anonymous = stamp(nil)
80
+ login = stamp("u1")
81
+ refute_equal anonymous, login
82
+ switch = stamp("u2")
83
+ refute_equal login, switch
84
+ logout = stamp(nil)
85
+ refute_equal switch, logout
86
+ # A fresh anonymous epoch, not the first one resurrected.
87
+ refute_equal anonymous, logout
88
+ end
89
+
90
+ # --- per-application isolation ----------------------------------------
91
+
92
+ def test_epochs_are_tracked_per_application_id
93
+ configure_user_key
94
+ epoch_a = stamp("u1")
95
+ @config.application_id = "second_app"
96
+ epoch_b = stamp("u1")
97
+ refute_equal epoch_a, epoch_b
98
+ # Rotating the second app's epoch leaves the first app's alone.
99
+ stamp("u2")
100
+ @config.application_id = "funicular"
101
+ assert_equal epoch_a, stamp("u1")
102
+ end
103
+
104
+ # --- session shape ------------------------------------------------------
105
+
106
+ def test_session_entry_uses_string_keys_for_json_round_trips
107
+ configure_user_key
108
+ epoch = stamp("u1")
109
+ entry = @session["funicular_epochs"]["funicular"]
110
+ assert_equal '["v1","funicular","user","u1"]', entry["identity"]
111
+ assert_equal epoch, entry["epoch"]
112
+ end
113
+
114
+ def test_a_corrupt_session_value_is_replaced_not_crashed_on
115
+ configure_user_key
116
+ @session["funicular_epochs"] = "garbage"
117
+ epoch = stamp("u1")
118
+ refute_empty epoch
119
+ assert_equal epoch,
120
+ @session["funicular_epochs"]["funicular"]["epoch"]
121
+ end
122
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # The tag whitelist lives in mrblib/0_tags.rb (runtime) and is manually
6
+ # enumerated in sig/tags.rbs and sig/view_context.rbs (types). These drifted
7
+ # in the past; fail loudly when they do again.
8
+ class SigTagsTest < Minitest::Test
9
+ ROOT = File.expand_path("..", __dir__)
10
+
11
+ def setup
12
+ Funicular::SSR::Runtime.load_framework!
13
+ end
14
+
15
+ def assert_sig_covers_all_tags(sig_path)
16
+ sig = File.read(File.join(ROOT, sig_path))
17
+ missing = Funicular::Tags::HTML_TAGS.reject do |tag|
18
+ sig.include?("def #{tag}: ")
19
+ end
20
+ assert_empty missing, "#{sig_path} is missing tag signatures: #{missing.join(', ')}"
21
+ end
22
+
23
+ def test_sig_tags_rbs_covers_all_tags
24
+ assert_sig_covers_all_tags("sig/tags.rbs")
25
+ end
26
+
27
+ def test_sig_view_context_rbs_covers_all_tags
28
+ assert_sig_covers_all_tags("sig/view_context.rbs")
29
+ end
30
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # The SSR contract for the local database (docs decision 18): model
6
+ # class bodies evaluate under CRuby -- declarations are recorded, no
7
+ # SQLite is touched -- but booting the database or materializing a
8
+ # local query on the server fails loud with UnavailableError, never an
9
+ # empty result.
10
+ class SSRDatabaseTest < Minitest::Test
11
+ def setup
12
+ # Loads the mrblib runtime under CRuby and flips Funicular.server
13
+ # to true, exactly like a Rails SSR render.
14
+ Funicular::SSR::Runtime.load_framework!
15
+ end
16
+
17
+ def test_boot_refuses_on_the_server
18
+ error = assert_raises(Funicular::DB::UnavailableError) do
19
+ Funicular::DB.boot(models: [], metadata: {})
20
+ end
21
+ assert_includes error.message, "SSR"
22
+ # The refusal is a clean raise, not a failed state: nothing to
23
+ # tear down, nothing latched.
24
+ assert_equal :unbooted, Funicular::DB.boot_state
25
+ end
26
+
27
+ def test_a_local_class_body_evaluates_but_materializing_raises
28
+ model = Class.new(Funicular::Model) do
29
+ table_name "ssr_drafts"
30
+ storage :local do
31
+ migrate 1 do |t|
32
+ t.string :title
33
+ end
34
+ end
35
+ end
36
+ # The declaration side worked: storage recorded, no SQLite touched.
37
+ assert_equal true, model.local?
38
+ # Materializing is where the server says no.
39
+ assert_raises(Funicular::DB::UnavailableError) { model.count }
40
+ assert_raises(Funicular::DB::UnavailableError) { model.local.to_a }
41
+ assert_raises(Funicular::DB::UnavailableError) do
42
+ model.local_create(title: "nope")
43
+ end
44
+ end
45
+
46
+ def test_association_declarations_evaluate_but_reading_raises
47
+ # Named constants, because the conventions read the class name:
48
+ # SsrPost -> ssr_post_id, :ssr_comment -> SsrComment.
49
+ Object.const_set(:SsrComment, Class.new(Funicular::Model) do
50
+ storage :local do
51
+ migrate 1 do |t|
52
+ t.integer :ssr_post_id
53
+ end
54
+ end
55
+ end)
56
+ Object.const_set(:SsrPost, Class.new(Funicular::Model))
57
+ # Declared after the constant exists, the way a `class SsrPost <
58
+ # Funicular::Model` body reads: has_many derives its foreign key
59
+ # from the declaring class name.
60
+ SsrPost.class_eval do
61
+ storage :local do
62
+ migrate 1 do |t|
63
+ t.integer :ssr_comment_id
64
+ end
65
+ end
66
+ belongs_to :ssr_comment
67
+ has_many :ssr_comments
68
+ end
69
+ # The declaration side worked: nothing resolved, no SQLite touched.
70
+ record = SsrPost.new(ssr_comment_id: 1)
71
+ # Reading one is a local query, and the server has no local database.
72
+ assert_raises(Funicular::DB::UnavailableError) { record.ssr_comment }
73
+ assert_raises(Funicular::DB::UnavailableError) { record.ssr_comments.to_a }
74
+ ensure
75
+ Object.send(:remove_const, :SsrPost) if Object.const_defined?(:SsrPost)
76
+ Object.send(:remove_const, :SsrComment) if Object.const_defined?(:SsrComment)
77
+ end
78
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tmpdir"
5
+
6
+ require "test_helper"
7
+
8
+ # Development-mode SSR reload: with auto_reload enabled (the railtie
9
+ # turns it on in development), editing an app source makes the next
10
+ # boot! re-load the sources, so server-rendered markup matches what the
11
+ # recompiled client bundle will hydrate.
12
+ class SSRReloadTest < Minitest::Test
13
+ def setup
14
+ Funicular::SSR::Runtime.load_framework!
15
+ end
16
+
17
+ def teardown
18
+ Funicular::SSR::Runtime.auto_reload = false
19
+ Funicular::SSR::Runtime.reset_app!
20
+ end
21
+
22
+ def write_component(dir, title)
23
+ File.write(File.join(dir, "components", "reload_probe_component.rb"), <<~RUBY)
24
+ class ReloadProbeComponent < Funicular::Component
25
+ def render
26
+ div { "#{title}" }
27
+ end
28
+ end
29
+ RUBY
30
+ end
31
+
32
+ def build_app(dir)
33
+ FileUtils.mkdir_p(File.join(dir, "components"))
34
+ write_component(dir, "before reload")
35
+ File.write(File.join(dir, "initializer.rb"), <<~RUBY)
36
+ Funicular.start(container: "app") do |router|
37
+ router.get("/probe", to: ReloadProbeComponent, as: "probe")
38
+ end
39
+ RUBY
40
+ end
41
+
42
+ def render_probe(dir)
43
+ Funicular::SSR.render(path: "/probe", source_dir: dir)[:html]
44
+ end
45
+
46
+ def quietly
47
+ original_verbose = $VERBOSE
48
+ $VERBOSE = nil
49
+ yield
50
+ ensure
51
+ $VERBOSE = original_verbose
52
+ end
53
+
54
+ def test_edited_sources_are_reloaded_when_auto_reload_is_on
55
+ Dir.mktmpdir do |dir|
56
+ build_app(dir)
57
+ Funicular::SSR::Runtime.reset_app!
58
+ assert_includes render_probe(dir), "before reload"
59
+
60
+ write_component(dir, "after reload")
61
+ bump_mtime(dir)
62
+
63
+ # Off (the default): still the stale markup.
64
+ assert_includes render_probe(dir), "before reload"
65
+
66
+ Funicular::SSR::Runtime.auto_reload = true
67
+ quietly do
68
+ assert_includes render_probe(dir), "after reload"
69
+ end
70
+ end
71
+ end
72
+
73
+ def test_reset_app_discards_the_snapshot
74
+ Dir.mktmpdir do |dir|
75
+ build_app(dir)
76
+ Funicular::SSR::Runtime.reset_app!
77
+ quietly { render_probe(dir) }
78
+ refute Funicular::SSR::Runtime.sources_changed?(dir)
79
+
80
+ Funicular::SSR::Runtime.reset_app!
81
+ assert Funicular::SSR::Runtime.sources_changed?(dir)
82
+ end
83
+ end
84
+
85
+ def test_unchanged_sources_are_not_reloaded
86
+ Dir.mktmpdir do |dir|
87
+ build_app(dir)
88
+ Funicular::SSR::Runtime.reset_app!
89
+ Funicular::SSR::Runtime.auto_reload = true
90
+ quietly { render_probe(dir) }
91
+
92
+ refute Funicular::SSR::Runtime.sources_changed?(dir)
93
+ end
94
+ end
95
+
96
+ private
97
+
98
+ # mtime comparison, not equality of content: make the edit observable
99
+ # even on filesystems with coarse timestamps.
100
+ def bump_mtime(dir)
101
+ future = Time.now + 2
102
+ Dir.glob(File.join(dir, "**", "*.rb")).each do |file|
103
+ File.utime(future, future, file)
104
+ end
105
+ end
106
+ end
data/minitest/ssr_test.rb CHANGED
@@ -122,6 +122,47 @@ class SSRTest < Minitest::Test
122
122
  assert_includes result[:html], "greeting"
123
123
  end
124
124
 
125
+ # --- Single-component render (no route lookup) ------------------------
126
+
127
+ def test_render_component_renders_by_name_without_a_route
128
+ html = Funicular::SSR.render_component(
129
+ "GreetingComponent",
130
+ state: { title: "Embedded" },
131
+ source_dir: APP_DIR
132
+ )
133
+ assert_includes html, "<h1>Embedded</h1>"
134
+ assert_includes html, 'class="greeting"'
135
+ end
136
+
137
+ def test_render_component_passes_props_and_state
138
+ html = Funicular::SSR.render_component(
139
+ "ProbeComponent",
140
+ props: { "label" => "From Props" },
141
+ state: { note: "injected note" },
142
+ source_dir: APP_DIR
143
+ )
144
+ assert_includes html, "<h2>From Props</h2>"
145
+ assert_includes html, "<p>injected note</p>"
146
+ end
147
+
148
+ def test_render_component_uses_initialize_state_without_injection
149
+ html = Funicular::SSR.render_component("GreetingComponent", source_dir: APP_DIR)
150
+ assert_includes html, "<h1>Default Title</h1>"
151
+ end
152
+
153
+ def test_render_component_raises_on_unknown_component
154
+ assert_raises(NameError) do
155
+ Funicular::SSR.render_component("NoSuchComponent", source_dir: APP_DIR)
156
+ end
157
+ end
158
+
159
+ def test_render_component_rejects_a_non_component_constant
160
+ error = assert_raises(ArgumentError) do
161
+ Funicular::SSR.render_component("String", source_dir: APP_DIR)
162
+ end
163
+ assert_match(/not a Funicular::Component/, error.message)
164
+ end
165
+
125
166
  def test_symbolize_keys_handles_nil_and_string_keys
126
167
  assert_equal({}, Funicular::SSR.symbolize_keys(nil))
127
168
  assert_equal({ a: 1, b: 2 }, Funicular::SSR.symbolize_keys("a" => 1, "b" => 2))
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tmpdir"
5
+
6
+ require "test_helper"
7
+
8
+ # Covers the freshness decision behind Funicular::Testing.ensure_compiled!.
9
+ # The actual compile (Node + vendored mrbc) is integration-only, matching
10
+ # the compiler tests' scope.
11
+ class TestingEnsureCompiledTest < Minitest::Test
12
+ def test_stale_when_bundle_is_missing
13
+ Dir.mktmpdir do |dir|
14
+ assert Funicular::Testing.stale?(File.join(dir, "app.mrb"), [])
15
+ end
16
+ end
17
+
18
+ def test_fresh_when_bundle_is_newer_than_every_source
19
+ Dir.mktmpdir do |dir|
20
+ source = File.join(dir, "component.rb")
21
+ output = File.join(dir, "app.mrb")
22
+ File.write(source, "")
23
+ File.write(output, "")
24
+ File.utime(Time.now - 60, Time.now - 60, source)
25
+
26
+ refute Funicular::Testing.stale?(output, [ source ])
27
+ end
28
+ end
29
+
30
+ def test_stale_when_any_source_is_newer_than_the_bundle
31
+ Dir.mktmpdir do |dir|
32
+ old_source = File.join(dir, "old.rb")
33
+ new_source = File.join(dir, "new.rb")
34
+ output = File.join(dir, "app.mrb")
35
+ [ old_source, new_source, output ].each { |f| File.write(f, "") }
36
+ File.utime(Time.now - 60, Time.now - 60, old_source)
37
+ File.utime(Time.now - 30, Time.now - 30, output)
38
+ File.utime(Time.now, Time.now, new_source)
39
+
40
+ assert Funicular::Testing.stale?(output, [ old_source, new_source ])
41
+ end
42
+ end
43
+
44
+ def test_missing_sources_do_not_count
45
+ Dir.mktmpdir do |dir|
46
+ output = File.join(dir, "app.mrb")
47
+ File.write(output, "")
48
+
49
+ refute Funicular::Testing.stale?(output, [ File.join(dir, "gone.rb") ])
50
+ end
51
+ end
52
+ end
@@ -30,6 +30,35 @@ class ValidationsTest < Minitest::Test
30
30
  assert_equal true, k.new("name" => "Alice").valid?
31
31
  end
32
32
 
33
+ def test_uncompilable_format_regex_is_skipped_not_fatal
34
+ k = model_class
35
+ out, _err = capture_io do
36
+ k.register_schema_validations(
37
+ "name" => {
38
+ "format" => { "with" => "[", "flags" => "" },
39
+ "presence" => true
40
+ }
41
+ )
42
+ end
43
+ # The warning names the rejected pattern so it can be found among
44
+ # many schemas loading at boot.
45
+ assert_match(/Skipping format validator/, out)
46
+ assert_match(/pattern: \[/, out)
47
+ # The broken format validator is dropped; the rest still applies and
48
+ # schema registration does not raise.
49
+ blank = k.new("name" => "")
50
+ assert_equal false, blank.valid?
51
+ assert_equal true, k.new("name" => "Alice").valid?
52
+ end
53
+
54
+ def test_schema_numericality_with_allow_nil_skips_nil
55
+ k = model_class({ "age" => false })
56
+ k.register_schema_validations("age" => { "numericality" => { "allow_nil" => true } })
57
+
58
+ assert_equal true, k.new("age" => nil).valid?
59
+ assert_equal false, k.new("age" => "abc").valid?
60
+ end
61
+
33
62
  def test_length_maximum_and_minimum
34
63
  k = model_class
35
64
  k.class_eval { validates :name, length: { minimum: 2, maximum: 5 } }
@@ -170,14 +199,15 @@ class ValidationsTest < Minitest::Test
170
199
  m = k.new("name" => "ok")
171
200
  m.instance_variable_set("@id", 1)
172
201
 
173
- got_success = :unset
174
202
  got_result = :unset
175
- m.update("name" => "") do |success, result|
176
- got_success = success
203
+ got_error = :unset
204
+ m.update("name" => "") do |result, error|
177
205
  got_result = result
206
+ got_error = error
178
207
  end
179
208
 
180
- assert_equal false, got_success
181
- assert_equal ["can't be blank"], got_result[:name]
209
+ # Unified 0.5.0 callback contract: (result, error), result nil on failure
210
+ assert_nil got_result
211
+ assert_equal ["can't be blank"], got_error[:name]
182
212
  end
183
213
  end
@@ -7,7 +7,7 @@ class ViewContextTest < Minitest::Test
7
7
  Funicular::SSR::Runtime.load_framework!
8
8
  end
9
9
 
10
- def test_explicit_state_styles_and_resources_do_not_collide_with_html_methods
10
+ def test_state_styles_and_resources_are_reachable_alongside_tags
11
11
  klass = Class.new(Funicular::Component) do
12
12
  styles { |css| css.define :hash, "hash-class" }
13
13
 
@@ -17,9 +17,9 @@ class ViewContextTest < Minitest::Test
17
17
  { class: "state-class", hash: "state-hash" }
18
18
  end
19
19
 
20
- def render(h)
21
- h.data(class: h.styles[:hash]) do
22
- "#{state[:class]} #{state[:hash]} #{h.resources[:data]}"
20
+ def render
21
+ data(class: styles[:hash]) do
22
+ "#{state[:class]} #{state[:hash]} #{resources[:data]}"
23
23
  end
24
24
  end
25
25
  end
@@ -37,15 +37,15 @@ class ViewContextTest < Minitest::Test
37
37
 
38
38
  def test_component_children_are_vdom_children_not_props
39
39
  child = Class.new(Funicular::Component) do
40
- def render(h)
41
- h.div { children }
40
+ def render
41
+ div { children }
42
42
  end
43
43
  end
44
44
 
45
45
  parent = Class.new(Funicular::Component) do
46
- define_method(:render) do |h|
47
- h.component(child, title: "x") do |hh|
48
- hh.span { "inside" }
46
+ define_method(:render) do
47
+ component(child, title: "x") do
48
+ span { "inside" }
49
49
  end
50
50
  end
51
51
  end
@@ -70,23 +70,23 @@ class ViewContextTest < Minitest::Test
70
70
  component_a.runtime = Funicular::Runtime.new(router_a)
71
71
  component_b.runtime = Funicular::Runtime.new(router_b)
72
72
 
73
- assert_equal "/users/7", Funicular::ViewContext.new(component_a).routes.user_path(7)
74
- assert_equal "/accounts/7", Funicular::ViewContext.new(component_b).routes.user_path(7)
73
+ assert_equal "/users/7", component_a.routes.user_path(7)
74
+ assert_equal "/accounts/7", component_b.routes.user_path(7)
75
75
  end
76
76
 
77
77
  def test_child_collector_is_restored_when_render_raises
78
78
  klass = Class.new(Funicular::Component) do
79
79
  attr_reader :captured
80
80
 
81
- def render(h)
81
+ def render
82
82
  begin
83
- h.div do |hh|
84
- hh.span { "before" }
83
+ div do
84
+ span { "before" }
85
85
  raise "boom"
86
86
  end
87
87
  rescue
88
88
  @captured = current_children
89
- h.div { "after" }
89
+ div { "after" }
90
90
  end
91
91
  end
92
92
  end
data/mrbgem.rake CHANGED
@@ -7,7 +7,9 @@ MRuby::Gem::Specification.new('picoruby-funicular') do |spec|
7
7
  spec.add_dependency 'picoruby-wasm'
8
8
  end
9
9
  spec.add_dependency 'picoruby-indexeddb'
10
+ spec.add_dependency 'picoruby-sqlite3'
10
11
  spec.add_dependency 'picoruby-json'
12
+ spec.add_dependency 'picoruby-uri'
11
13
  spec.add_dependency 'mruby-object-ext', gemdir: "#{MRUBY_ROOT}/mrbgems/picoruby-mruby/lib/mruby/mrbgems/mruby-object-ext"
12
14
  spec.add_dependency 'mruby-hash-ext', gemdir: "#{MRUBY_ROOT}/mrbgems/picoruby-mruby/lib/mruby/mrbgems/mruby-hash-ext"
13
15
  spec.add_dependency 'mruby-array-ext', gemdir: "#{MRUBY_ROOT}/mrbgems/picoruby-mruby/lib/mruby/mrbgems/mruby-array-ext"
data/mrblib/0_tags.rb ADDED
@@ -0,0 +1,62 @@
1
+ module Funicular
2
+ # Raised at class-definition time (method_added) or first mount when a
3
+ # component defines a method whose name collides with the view DSL.
4
+ class DSLCollisionError < StandardError; end
5
+
6
+ # Raised when a tag helper is called while the component is not rendering
7
+ # (e.g. from a stale proc or an event handler outside build_vdom).
8
+ class RenderContextError < StandardError; end
9
+
10
+ # Bareword tag DSL mixed into Funicular::Component. Tags are real public
11
+ # instance methods so `div(...)` works with self = the component inside
12
+ # `render`. Element construction is delegated to the component's internal
13
+ # ViewContext, which remains the single element factory (FormBuilder and
14
+ # the framework internals keep using it directly).
15
+ module Tags
16
+ # Single source of truth for the tag whitelist. sig/tags.rbs must list
17
+ # one method per entry; test/sig_tags_test.rb enforces that in CI.
18
+ HTML_TAGS = %w[
19
+ div span p a data
20
+ h1 h2 h3 h4 h5 h6
21
+ ul ol li
22
+ table thead tbody tr th td
23
+ form input textarea button select option label
24
+ header footer nav section article aside
25
+ img video audio canvas
26
+ br hr
27
+ ]
28
+
29
+ HELPER_NAMES = %w[
30
+ tag component form_for link_to button_to suspense
31
+ state props styles resources routes patch
32
+ ]
33
+
34
+ # Symbol => :tag | :helper. Component.method_added consults this to turn
35
+ # silent shadowing into a load-time error.
36
+ RESERVED_DSL = {} #: Hash[Symbol, Symbol]
37
+ HTML_TAGS.each { |name| RESERVED_DSL[name.to_sym] = :tag }
38
+ HELPER_NAMES.each { |name| RESERVED_DSL[name.to_sym] = :helper }
39
+
40
+ HTML_TAGS.each do |tag_name|
41
+ define_method(tag_name) do |props = {}, &block|
42
+ # @type self: Funicular::Component
43
+ unless props.is_a?(Hash)
44
+ hint = if tag_name == "p"
45
+ " Inside a component, `p` builds a <p> element; use `puts x.inspect` to debug."
46
+ else
47
+ ""
48
+ end
49
+ raise ArgumentError, "#{tag_name}() expects an attributes Hash, got #{props.class}.#{hint}"
50
+ end
51
+ __view__.tag(tag_name, props, &block)
52
+ end
53
+ end
54
+
55
+ # Escape hatch: emit any element, including custom elements outside the
56
+ # whitelist or a tag whose bareword is shadowed via allow_dsl_override.
57
+ def tag(name, props = {}, &block)
58
+ # @type self: Funicular::Component
59
+ __view__.tag(name, props, &block)
60
+ end
61
+ end
62
+ end
data/mrblib/cable.rb CHANGED
@@ -229,7 +229,7 @@ module Funicular
229
229
 
230
230
  # Setup beforeunload handler to persist pending commands
231
231
  def setup_beforeunload_handler
232
- @beforeunload_callback_id = JS.global.addEventListener("beforeunload") do
232
+ @beforeunload_callback_id = JS.global.addEventListener("beforeunload", sync: true) do
233
233
  save_pending_to_storage
234
234
  end
235
235
  end