funicular 0.2.1 → 0.4.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +111 -0
  3. data/README.md +2 -2
  4. data/Rakefile +23 -15
  5. data/demo/test_chartjs.html +8 -8
  6. data/demo/test_component.html +8 -8
  7. data/demo/test_error_boundary.html +8 -5
  8. data/demo/test_router.html +11 -11
  9. data/demo/tic-tac-toe.html +4 -4
  10. data/docs/architecture.md +80 -30
  11. data/lib/funicular/compiler.rb +13 -13
  12. data/lib/funicular/helpers/picoruby_helper.rb +24 -1
  13. data/lib/funicular/ssr/runtime.rb +3 -0
  14. data/lib/funicular/ssr.rb +2 -1
  15. data/lib/funicular/testing/node_runner.rb +1 -1
  16. data/lib/funicular/vendor/mrbc/VERSION +1 -0
  17. data/lib/funicular/vendor/{picorbc/picorbc.js → mrbc/mrbc.js} +626 -486
  18. data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
  19. data/lib/funicular/vendor/picoruby/VERSION +1 -1
  20. data/lib/funicular/vendor/picoruby/debug/init.iife.js +19 -4
  21. data/lib/funicular/vendor/picoruby/debug/picoruby.js +675 -449
  22. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  23. data/lib/funicular/vendor/picoruby/dist/init.iife.js +19 -4
  24. data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -2
  25. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  26. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -1
  27. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +860 -567
  28. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  29. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -1
  30. data/lib/funicular/version.rb +1 -1
  31. data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +25 -25
  32. data/lib/tasks/funicular.rake +1 -1
  33. data/minitest/commands_routes_test.rb +97 -0
  34. data/minitest/compiler_test.rb +195 -0
  35. data/minitest/configuration_test.rb +64 -0
  36. data/minitest/dsl_test.rb +237 -0
  37. data/minitest/fixtures/funicular_app/components/greeting_component.rb +2 -2
  38. data/minitest/funicular_test.rb +28 -2
  39. data/minitest/middleware_test.rb +154 -0
  40. data/minitest/picoruby_helper_test.rb +139 -0
  41. data/minitest/route_parser_test.rb +139 -0
  42. data/minitest/schema_test.rb +23 -0
  43. data/minitest/sig_tags_test.rb +30 -0
  44. data/minitest/ssr_test.rb +57 -0
  45. data/minitest/support/rails_stub.rb +59 -0
  46. data/minitest/test_helper.rb +20 -0
  47. data/minitest/testing_test.rb +267 -0
  48. data/minitest/view_context_test.rb +101 -0
  49. data/mrblib/0_tags.rb +62 -0
  50. data/mrblib/component.rb +255 -244
  51. data/mrblib/debug.rb +7 -6
  52. data/mrblib/differ.rb +3 -1
  53. data/mrblib/error_boundary.rb +11 -13
  54. data/mrblib/form_builder.rb +28 -24
  55. data/mrblib/funicular.rb +2 -1
  56. data/mrblib/html_serializer.rb +14 -13
  57. data/mrblib/http.rb +12 -1
  58. data/mrblib/patcher.rb +12 -9
  59. data/mrblib/router.rb +9 -5
  60. data/mrblib/runtime.rb +28 -0
  61. data/mrblib/styles.rb +107 -21
  62. data/mrblib/vdom.rb +90 -9
  63. data/mrblib/view_context.rb +106 -0
  64. data/sig/component.rbs +47 -84
  65. data/sig/form_builder.rbs +2 -1
  66. data/sig/html_serializer.rbs +2 -1
  67. data/sig/http.rbs +1 -0
  68. data/sig/patcher.rbs +1 -1
  69. data/sig/router.rbs +2 -13
  70. data/sig/runtime.rbs +13 -0
  71. data/sig/styles.rbs +19 -7
  72. data/sig/tags.rbs +54 -0
  73. data/sig/vdom.rbs +11 -2
  74. data/sig/view_context.rbs +60 -0
  75. metadata +22 -5
  76. data/lib/funicular/vendor/picorbc/VERSION +0 -1
  77. data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fa8a7ba40464c4e28ecc1b784612a9a62a8a88af985efd2508f142e03443fe9a
4
- data.tar.gz: a81683de0aa13f2d91f21ccbddf6b8ed802f673567591adee4b12de6d0453d91
3
+ metadata.gz: 5816e8e43d54e6bec4b6bb8a06da6165ab138e549cdbad649d2acba002a5fec4
4
+ data.tar.gz: 51ed5ca984f12669bfd1587bf384ea685c54e2d03dff9552d3cc49485d29081f
5
5
  SHA512:
6
- metadata.gz: b2809132d4fc00812c2443c2fb42e6744bbb25449beee2cb03c2b6cd2302792da21ba7d6a98ac462f7bcdc10b17af87988fa8fb38e635fa747d3bbba1e346c60
7
- data.tar.gz: df0f03841a9f97ab6269f088be4b16a488430419cf7b5d196615488d098d149c6241bc08f495b477f3f539a72df13725412be09768fb90c3d96560677c9e2390
6
+ metadata.gz: 360c39bdc7375ae5967c37f0b24437fa6d254f8814520ae984cc455195ac957b1279988a430f752e731eb40eab62cbeba178bc1ad08dc8836c7ce64c0d0810d5
7
+ data.tar.gz: 31bb97a1b6fbedbf3d1b1539c3e8ea5a8169c91095733c09d38f465c463a56071ecb766524945e307b66998708e09fff0ce0f6388e009f54b9d9945169922259
data/CHANGELOG.md CHANGED
@@ -2,6 +2,117 @@
2
2
 
3
3
  ### Added
4
4
 
5
+ - 0.4.0 bareword component DSL: `render` (zero-arity) runs with `self` as
6
+ the component, so HTML tags, `component`, `form_for`, `link_to`,
7
+ `button_to`, `suspense`, `state`, `props`, `styles`, `resources`, and
8
+ `routes` are all called bareword, without the 0.3.0 `h.` receiver.
9
+ - DSL collision detection: tag and helper names are reserved inside
10
+ component classes. Defining one raises `Funicular::DSLCollisionError` at
11
+ class-definition time (`method_added`) or at first mount
12
+ (`validate_dsl_conflicts!`, covering `attr_*` on mruby and included
13
+ modules). `allow_dsl_override :name` opts out per class; the shadowed
14
+ element stays reachable via `tag(:name, ...)`.
15
+ - Bareword style definitions: the class-level `styles do ... end` block
16
+ runs on a `BasicObject` cleanroom builder, so any name (including
17
+ `display`, `hash`, ...) defines a style identically on mruby and CRuby.
18
+ The explicit `styles { |css| css.define(...) }` form remains for
19
+ computed values.
20
+ - Generated style accessors: each declared style name becomes a real
21
+ method on a per-component accessor, e.g. `styles.button(:disabled)`;
22
+ the `styles[:name, variant]` form is kept.
23
+
24
+ ### Breaking Changes
25
+
26
+ - **0.4.0 is a breaking DSL change against 0.3.0. Components written for
27
+ 0.3.0 migrate mechanically: delete the `render(h)` parameter, drop the
28
+ `h.` receivers, convert `css.define :name, "..."` to bareword
29
+ `name "..."`, and `h.styles[:name, variant]` to
30
+ `styles.name(variant)`.**
31
+ - `p` inside a component builds a `<p>` element. Debug with
32
+ `puts x.inspect`; a non-Hash argument to any tag raises `ArgumentError`
33
+ with a hint.
34
+ - A local variable named after a tag shadows the zero-paren call form
35
+ (plain Ruby scoping); write `option()` or rename the local.
36
+ - Tag and helper names (RESERVED_DSL) can no longer be defined as
37
+ component methods without `allow_dsl_override`.
38
+ - Style lookups of unknown names raise (`NoMethodError` for
39
+ `styles.typo`, `ArgumentError` for `styles[:typo]`) instead of
40
+ silently returning an empty class string. Style definition values are
41
+ validated (String / Hash / keyword options; unknown option keys raise).
42
+ - Tag helpers called while the component is not rendering raise
43
+ `Funicular::RenderContextError` instead of being silently dropped.
44
+ - `ErrorBoundary` `fallback:`/`error:` procs keep an explicit view
45
+ context (`->(h, error) { h.div { ... } }`): they are created in the
46
+ parent's scope but run during the boundary's render, so barewords
47
+ cannot work there by design.
48
+ - `Component#render_suspense` no longer takes a view context; suspense
49
+ `fallback:`/`error:`/content procs run bareword in their own component
50
+ (`fallback: -> { div { "Loading" } }`).
51
+
52
+ ### Changed
53
+
54
+ - Requires picoruby-wasm with `JS::Object < BasicObject` (picoruby
55
+ 9e69333f): Kernel names (`hash`, `send`, `open`, ...) no longer shadow
56
+ JS property access, and unknown `?`/`!` methods on JS values raise.
57
+
58
+ ## [0.3.0] - 2026-07-13
59
+
60
+ ### Added
61
+
62
+ - 0.3.0 rendering architecture: `render(h)` now receives a `ViewContext`
63
+ facade for elements, components, forms, styles, resources, and routes.
64
+ - Per-app `Runtime` context for route helpers and renderer/serializer
65
+ propagation, enabling isolated route helper sets across multiple apps.
66
+
67
+ ### Breaking Changes
68
+
69
+ - **0.3.0 is a deliberate breaking DSL redesign. Existing Funicular
70
+ components written for 0.2.x require source changes.**
71
+ - Component render methods must now accept a view context:
72
+ `def render(h)`. The former implicit component-level DSL methods for HTML
73
+ tags, `component`, `form_for`, `link_to`, `button_to`, `suspense`, styles,
74
+ resources, and route helpers have been removed.
75
+ - HTML and framework helpers are now called through `h`, for example
76
+ `h.div`, `h.component(...)`, `h.form_for(...)`, `h.link_to(...)`,
77
+ `h.suspense(...)`, `h.styles[...]`, `h.resources[...]`, and `h.routes`.
78
+ - Component state reads are explicit: use `state[:key]`, `state.fetch(:key)`,
79
+ or `h.state[:key]`. The old `state.key_name` method-style access has been
80
+ removed.
81
+ - Style definitions are explicit: use `styles { |css| css.define(...) }`.
82
+ The old dynamic style definition DSL has been removed.
83
+ - Component children are stored as `VDOM::Component#children`. The old
84
+ `children_block` prop path has been removed and no compatibility shim is
85
+ provided.
86
+ - Route helpers are scoped by `Funicular::Runtime`; global
87
+ `Funicular::RouteHelpers` injection has been removed. Code that depends on
88
+ route helpers should use `h.routes`.
89
+ - `FormBuilder`, `ErrorBoundary`, SSR, hydration, renderer, patcher, and HTML
90
+ serialization now operate through the same `ViewContext` / `Runtime`
91
+ architecture.
92
+
93
+ ### Changed
94
+
95
+ - Since mruby-compiler-prism, which used to be mruby-compiler2 producing
96
+ picorbc, has become the default compiler for mruby, we changed the name
97
+ from picorbc to mrbc.
98
+
99
+ ### Fixed
100
+
101
+ - Harden VDOM rendering against HTML and script injection in both SSR and
102
+ browser rendering: validate tag and attribute names, reject `script`
103
+ elements, and consistently block case-obfuscated event handlers, `srcdoc`,
104
+ and unsafe URL schemes including control-character variants.
105
+
106
+ ## [0.2.1] - 2026-06-15
107
+
108
+ ### Added
109
+
110
+ - **Funicular::Component**: Add `name` field to form to find state changed.
111
+
112
+ ## [0.2.0] - 2026-06-11
113
+
114
+ ### Added
115
+
5
116
  - **Funicular::Store DSL**: Declarative client-side stores backed by
6
117
  IndexedDB. Subclass `Funicular::Store::Singleton` (one value per scope)
7
118
  or `Funicular::Store::Collection` (ordered list per scope) and use
data/README.md CHANGED
@@ -72,13 +72,13 @@ git clone --recurse-submodules https://github.com/picoruby/picoruby.git
72
72
  cd picoruby/mrbgems/picoruby-funicular
73
73
  ```
74
74
 
75
- The CRubyGem side (`lib/`, `funicular.gemspec`, etc.) can be developed and tested independently inside that directory, but `rake copy_wasm` — which vendors the PicoRuby.wasm and picorbc wasm artifacts into the gem — relies on sibling directories within the picoruby repository (`mrbgems/picoruby-wasm/npm/`).
75
+ The CRubyGem side (`lib/`, `funicular.gemspec`, etc.) can be developed and tested independently inside that directory, but `rake copy_wasm` — which vendors the PicoRuby.wasm and mrbc wasm artifacts into the gem — relies on sibling directories within the picoruby repository (`mrbgems/picoruby-wasm/npm/`).
76
76
  Running it from a standalone checkout will fail.
77
77
 
78
78
  ## Testing
79
79
 
80
80
  - CRubygem (Rails integration) test: `rake test` in this repository
81
- - PicoGem Funicular test: `rake test:gems:picoruby[picoruby-funicular]` in picoruby where mrbgems/picoruby-funicular exists as a submodule
81
+ - PicoGem Funicular test: `rake test:gems:wasm[picoruby-funicular]` in picoruby where mrbgems/picoruby-funicular exists as a submodule
82
82
 
83
83
  ## Contributing
84
84
 
data/Rakefile CHANGED
@@ -9,9 +9,17 @@ Rake::TestTask.new(:test) do |t|
9
9
  t.test_files = FileList["minitest/**/*_test.rb"]
10
10
  end
11
11
 
12
+ namespace :test do
13
+ desc "Run the test suite with SimpleCov coverage measurement (report in coverage/)"
14
+ task :coverage do
15
+ ENV["COVERAGE"] = "1"
16
+ Rake::Task["test"].invoke
17
+ end
18
+ end
19
+
12
20
  task default: :test
13
21
 
14
- desc "Copy picoruby and picorbc wasm artifacts from picoruby-wasm into the gem"
22
+ desc "Copy picoruby and mrbc wasm artifacts from picoruby-wasm into the gem"
15
23
  task :copy_wasm do
16
24
  require "fileutils"
17
25
  require "json"
@@ -59,25 +67,25 @@ task :copy_wasm do
59
67
  puts " wrote picoruby/VERSION (#{picoruby_version})"
60
68
 
61
69
  # ------------------------------------------------------------------
62
- # 2) picorbc compiler (node CLI, run by Funicular::Compiler)
70
+ # 2) mrbc compiler (node CLI, run by Funicular::Compiler)
63
71
  # ------------------------------------------------------------------
64
- picorbc_src = File.join(npm_root, "picorbc", "debug")
65
- picorbc_dest = File.join(vendor_root, "picorbc")
66
- abort "Missing #{picorbc_src}" unless Dir.exist?(picorbc_src)
72
+ mrbc_src = File.join(npm_root, "mrbc", "debug")
73
+ mrbc_dest = File.join(vendor_root, "mrbc")
74
+ abort "Missing #{mrbc_src}" unless Dir.exist?(mrbc_src)
67
75
 
68
- picorbc_version = JSON.parse(File.read(File.join(npm_root, "picorbc", "package.json"))).fetch("version")
69
- picorbc_files = %w[picorbc.js picorbc.wasm]
76
+ mrbc_version = JSON.parse(File.read(File.join(npm_root, "mrbc", "package.json"))).fetch("version")
77
+ mrbc_files = %w[mrbc.js mrbc.wasm]
70
78
 
71
- FileUtils.rm_rf(picorbc_dest)
72
- FileUtils.mkdir_p(picorbc_dest)
73
- picorbc_files.each do |fname|
74
- src_file = File.join(picorbc_src, fname)
79
+ FileUtils.rm_rf(mrbc_dest)
80
+ FileUtils.mkdir_p(mrbc_dest)
81
+ mrbc_files.each do |fname|
82
+ src_file = File.join(mrbc_src, fname)
75
83
  abort "Missing file: #{src_file}" unless File.exist?(src_file)
76
- FileUtils.copy_file(src_file, File.join(picorbc_dest, fname))
84
+ FileUtils.copy_file(src_file, File.join(mrbc_dest, fname))
77
85
  end
78
- File.chmod(0755, File.join(picorbc_dest, "picorbc.js"))
79
- File.write(File.join(picorbc_dest, "VERSION"), "#{picorbc_version}\n")
80
- puts " copied picorbc (#{picorbc_version})"
86
+ File.chmod(0755, File.join(mrbc_dest, "mrbc.js"))
87
+ File.write(File.join(mrbc_dest, "VERSION"), "#{mrbc_version}\n")
88
+ puts " copied mrbc (#{mrbc_version})"
81
89
 
82
90
  # ------------------------------------------------------------------
83
91
  # 3) PicoRuby runtime for DOM-backed Node.js tests
@@ -44,7 +44,7 @@
44
44
  return
45
45
  end
46
46
 
47
- current_data = state.data_sets[state.month_index]
47
+ current_data = state[:data_sets][state[:month_index]]
48
48
 
49
49
  config = {
50
50
  type: 'line',
@@ -69,7 +69,7 @@
69
69
  end
70
70
 
71
71
  def component_updated
72
- puts "component_updated: Updating Chart.js with data index #{state.month_index}"
72
+ puts "component_updated: Updating Chart.js with data index #{state[:month_index]}"
73
73
  update_chart
74
74
  end
75
75
 
@@ -80,22 +80,22 @@
80
80
 
81
81
  def next_data_set(event)
82
82
  event.preventDefault if event
83
- new_index = (state.month_index + 1) % state.data_sets.size
84
- puts "next_data_set: Changing index from #{state.month_index} to #{new_index}"
83
+ new_index = (state[:month_index] + 1) % state[:data_sets].size
84
+ puts "next_data_set: Changing index from #{state[:month_index]} to #{new_index}"
85
85
  patch(month_index: new_index)
86
86
  end
87
87
 
88
88
  def prev_data_set(event)
89
89
  event.preventDefault if event
90
- new_index = (state.month_index - 1) % state.data_sets.size
91
- puts "prev_data_set: Changing index from #{state.month_index} to #{new_index}"
90
+ new_index = (state[:month_index] - 1) % state[:data_sets].size
91
+ puts "prev_data_set: Changing index from #{state[:month_index]} to #{new_index}"
92
92
  patch(month_index: new_index)
93
93
  end
94
94
 
95
95
  def update_chart
96
96
  return unless @chart
97
97
 
98
- current_data = state.data_sets[state.month_index]
98
+ current_data = state[:data_sets][state[:month_index]]
99
99
  puts "update_chart: Setting data to #{current_data[:labels].join(', ')}"
100
100
 
101
101
  # Convert to JS objects
@@ -129,7 +129,7 @@
129
129
  button(id: "next-btn", onclick: :next_data_set) { "Next Quarter" }
130
130
  end
131
131
  div(id: "info") do
132
- current = state.data_sets[state.month_index]
132
+ current = state[:data_sets][state[:month_index]]
133
133
  "Current data: #{current[:labels].join(', ')}"
134
134
  end
135
135
  end
@@ -40,26 +40,26 @@
40
40
 
41
41
  def increment(event)
42
42
  event.preventDefault if event
43
- patch(count: state.count + 1)
43
+ patch(count: state[:count] + 1)
44
44
  end
45
45
 
46
46
  def decrement(event)
47
47
  event.preventDefault if event
48
- patch(count: state.count - 1)
48
+ patch(count: state[:count] - 1)
49
49
  end
50
50
 
51
51
  def render
52
52
  div(id: "counter-app") do
53
- h2 { "Counter: #{state.count}" }
53
+ h2 { "Counter: #{state[:count]}" }
54
54
  div(id: "status") do
55
- "Mounted: #{state.mounted}"
55
+ "Mounted: #{state[:mounted]}"
56
56
  end
57
57
  div do
58
58
  button(id: "increment-btn", onclick: :increment) { "+1" }
59
59
  button(id: "decrement-btn", onclick: :decrement) { "-1" }
60
60
  end
61
61
  div(ref: :display, id: "display") do
62
- "Current value: #{state.count}"
62
+ "Current value: #{state[:count]}"
63
63
  end
64
64
  end
65
65
  end
@@ -89,7 +89,7 @@
89
89
  # Test 2: Initial state
90
90
  begin
91
91
  counter = TestCounter.new
92
- initial_state = counter.state.count == 0
92
+ initial_state = counter.state[:count] == 0
93
93
  log_test("Initial state", initial_state)
94
94
  rescue => e
95
95
  log_test("Initial state", false)
@@ -139,13 +139,13 @@
139
139
  has_button = btn != nil
140
140
 
141
141
  # Test handler logic by calling it directly (standard testing approach)
142
- initial_count = counter.state.count
142
+ initial_count = counter.state[:count]
143
143
  counter.increment(nil)
144
144
  sleep 0.05
145
145
 
146
146
  h2_element = JS.document.querySelector('#counter-app h2')
147
147
  text = h2_element.textContent.to_s
148
- handler_works = counter.state.count == initial_count + 1 && text.include?("#{initial_count + 1}")
148
+ handler_works = counter.state[:count] == initial_count + 1 && text.include?("#{initial_count + 1}")
149
149
 
150
150
  log_test("Event handler (click)", has_button && handler_works)
151
151
  rescue => e
@@ -125,11 +125,14 @@
125
125
  puts " Component: #{info&.dig(:component_class)}"
126
126
  }
127
127
 
128
- custom_fallback = ->(error) {
129
- div(style: 'background: linear-gradient(135deg, #ff6b6b, #ee5a5a); color: white; padding: 20px; border-radius: 8px;') do
130
- h3(style: 'margin-top: 0;') { "Custom Error Handler" }
131
- p { "Caught: #{error.message}" }
132
- p(style: 'font-size: 12px; opacity: 0.8;') { "This is a custom fallback provided via props." }
128
+ # NOTE: an ErrorBoundary fallback runs during the BOUNDARY's render,
129
+ # not this component's, so it cannot use bareword tags. It receives
130
+ # the boundary's view context and builds elements through it.
131
+ custom_fallback = ->(h, error) {
132
+ h.div(style: 'background: linear-gradient(135deg, #ff6b6b, #ee5a5a); color: white; padding: 20px; border-radius: 8px;') do
133
+ h.h3(style: 'margin-top: 0;') { "Custom Error Handler" }
134
+ h.p { "Caught: #{error.message}" }
135
+ h.p(style: 'font-size: 12px; opacity: 0.8;') { "This is a custom fallback provided via props." }
133
136
  end
134
137
  }
135
138
 
@@ -71,7 +71,7 @@
71
71
  input(
72
72
  type: 'text',
73
73
  placeholder: 'Username',
74
- value: state.username,
74
+ value: state[:username],
75
75
  oninput: :on_username_input
76
76
  )
77
77
  end
@@ -81,7 +81,7 @@
81
81
  end
82
82
 
83
83
  div(class: 'status') do
84
- "Username: #{state.username}"
84
+ "Username: #{state[:username]}"
85
85
  end
86
86
  end
87
87
  end
@@ -94,7 +94,7 @@
94
94
 
95
95
  def increment(event)
96
96
  event.preventDefault
97
- patch(count: state.count + 1)
97
+ patch(count: state[:count] + 1)
98
98
  end
99
99
 
100
100
  def go_to_settings(event)
@@ -107,7 +107,7 @@
107
107
  h2(class: 'page-title') { 'Home Page' }
108
108
 
109
109
  div do
110
- "Counter: #{state.count}"
110
+ "Counter: #{state[:count]}"
111
111
  end
112
112
 
113
113
  div do
@@ -129,13 +129,13 @@
129
129
 
130
130
  def toggle_theme(event)
131
131
  event.preventDefault
132
- new_theme = state.theme == 'light' ? 'dark' : 'light'
132
+ new_theme = state[:theme] == 'light' ? 'dark' : 'light'
133
133
  patch(theme: new_theme)
134
134
  end
135
135
 
136
136
  def toggle_notifications(event)
137
137
  event.preventDefault
138
- patch(notifications: !state.notifications)
138
+ patch(notifications: !state[:notifications])
139
139
  end
140
140
 
141
141
  def go_to_home(event)
@@ -148,12 +148,12 @@
148
148
  h2(class: 'page-title') { 'Settings Page' }
149
149
 
150
150
  div do
151
- span { "Theme: #{state.theme}" }
151
+ span { "Theme: #{state[:theme]}" }
152
152
  button(onclick: :toggle_theme) { 'Toggle Theme' }
153
153
  end
154
154
 
155
155
  div do
156
- span { "Notifications: #{state.notifications ? 'ON' : 'OFF'}" }
156
+ span { "Notifications: #{state[:notifications] ? 'ON' : 'OFF'}" }
157
157
  button(onclick: :toggle_notifications) { 'Toggle Notifications' }
158
158
  end
159
159
 
@@ -190,7 +190,7 @@
190
190
  h2(class: 'page-title') { 'Users List' }
191
191
 
192
192
  ul do
193
- state.users.each do |user|
193
+ state[:users].each do |user|
194
194
  li do
195
195
  a(href: "/users/#{user[:id]}", onclick: go_to_user(user[:id])) do
196
196
  user[:name]
@@ -221,7 +221,7 @@
221
221
  h2(class: 'page-title') { "User Detail" }
222
222
 
223
223
  div do
224
- "Viewing user ID: #{state.user_id}"
224
+ "Viewing user ID: #{state[:user_id]}"
225
225
  end
226
226
 
227
227
  div do
@@ -229,7 +229,7 @@
229
229
  end
230
230
 
231
231
  div(class: 'status') do
232
- "This page demonstrates dynamic routing with params[:id] = #{state.user_id}"
232
+ "This page demonstrates dynamic routing with params[:id] = #{state[:user_id]}"
233
233
  end
234
234
  end
235
235
  end
@@ -96,8 +96,8 @@
96
96
  end
97
97
 
98
98
  def handle_play(i)
99
- history = state.history
100
- current_move = state.current_move
99
+ history = state[:history]
100
+ current_move = state[:current_move]
101
101
  current_squares = history[current_move].dup
102
102
 
103
103
  current_squares[i] = (current_move % 2 == 0) ? 'X' : 'O'
@@ -116,8 +116,8 @@
116
116
  end
117
117
 
118
118
  def render
119
- history = state.history
120
- current_move = state.current_move
119
+ history = state[:history]
120
+ current_move = state[:current_move]
121
121
  x_is_next = (current_move % 2 == 0)
122
122
  current_squares = history[current_move]
123
123
 
data/docs/architecture.md CHANGED
@@ -26,34 +26,67 @@ stay free of browser-only calls on any server code path
26
26
 
27
27
  ## `mrblib/` runtime: responsibilities
28
28
 
29
- | File(s) | Responsibility |
30
- |--------------------------------------------------|---------------------------------------------------------------------------|
31
- | `funicular.rb` | Top-level module: `start`, `router`, `server?`, `debug_color` export |
32
- | `component.rb` | `Funicular::Component` base: state, props, lifecycle, suspense, refs, styles |
33
- | `vdom.rb` | Virtual DOM nodes and the element-factory DSL (`div`, `button`, ...) |
34
- | `differ.rb` | `Differ.diff(old, new)` -- minimal patch set, key-based list reconciliation |
35
- | `patcher.rb` | `Patcher.apply(dom, patches)` -- apply patches to the real DOM |
36
- | `html_serializer.rb` | `VDOM::HTMLSerializer` -- VDOM to HTML string (used by SSR) |
37
- | `router.rb` | Client-side router, route DSL, `RouteHelpers` generation, History API |
38
- | `model.rb` | Object-REST Mapper (`all`/`find`/`create`/`update`/`destroy`) |
39
- | `http.rb` | Low-level fetch wrapper, CSRF, IndexedDB response cache |
40
- | `cable.rb` | ActionCable-compatible consumer/subscription client |
41
- | `store.rb`, `store_singleton.rb`, `store_collection.rb` | IndexedDB-backed stores, scope API, `subscribes_to`, event dispatch |
42
- | `form_builder.rb` | `form_for` and field helpers with inline error rendering |
43
- | `0_validations.rb`, `1_validators.rb` | ActiveModel-style validators and `errors` |
44
- | `styles.rb` | CSS-in-Ruby `styles` DSL and the `s` helper |
45
- | `error_boundary.rb` | `ErrorBoundary` component |
46
- | `file_upload.rb` | File / FormData upload helper |
47
- | `debug.rb` | Development-only component/error registry for the DevTools extension |
48
- | `environment_inquirer.rb` | Environment detection (`server?`, `development?`) |
29
+ | File(s) | Responsibility |
30
+ |---------------------------------------------------------|-------------------------------------------------------------------------------------------------|
31
+ | `funicular.rb` | Top-level module: `start`, `router`, `server?`, `debug_color` export |
32
+ | `runtime.rb` | Per-app runtime context propagated through render/SSR/hydration |
33
+ | `0_tags.rb` | Bareword tag DSL mixed into `Component`; reserved-name list and collision errors |
34
+ | `view_context.rb` | Internal element factory shared by the tag DSL, FormBuilder, and framework helpers |
35
+ | `component.rb` | `Funicular::Component` base: state, props, lifecycle, suspense loading, refs |
36
+ | `vdom.rb` | Virtual DOM nodes, including component vnodes with ordinary `children` |
37
+ | `differ.rb` | `Differ.diff(old, new)` -- minimal patch set, key-based list reconciliation |
38
+ | `patcher.rb` | `Patcher.apply(dom, patches)` -- apply patches to the real DOM |
39
+ | `html_serializer.rb` | `VDOM::HTMLSerializer` -- VDOM to HTML string (used by SSR) |
40
+ | `router.rb` | Client-side router, route DSL, per-runtime route helper object, History API |
41
+ | `model.rb` | Object-REST Mapper (`all`/`find`/`create`/`update`/`destroy`) |
42
+ | `http.rb` | Low-level fetch wrapper, CSRF, IndexedDB response cache |
43
+ | `cable.rb` | ActionCable-compatible consumer/subscription client |
44
+ | `store.rb`, `store_singleton.rb`, `store_collection.rb` | IndexedDB-backed stores, scope API, `subscribes_to`, event dispatch |
45
+ | `form_builder.rb` | `form_for` field helpers with inline error rendering |
46
+ | `0_validations.rb`, `1_validators.rb` | ActiveModel-style validators and `errors` |
47
+ | `styles.rb` | CSS-in-Ruby bareword `styles do ... end` builder and generated `styles.name` accessors |
48
+ | `error_boundary.rb` | `ErrorBoundary` component |
49
+ | `file_upload.rb` | File / FormData upload helper |
50
+ | `debug.rb` | Development-only component/error registry for the DevTools extension |
51
+ | `environment_inquirer.rb` | Environment detection (`server?`, `development?`) |
49
52
 
50
53
  The render cycle: a state change calls `patch()`, which rebuilds the component's
51
- VDOM, diffs it against the previous VDOM with `Differ`, and applies the result
52
- with `Patcher`. Event handlers are native DOM listeners, re-bound on each render.
54
+ VDOM by calling `render`, diffs it against the previous VDOM with `Differ`,
55
+ and applies the result with `Patcher`. Event handlers are native DOM listeners,
56
+ re-bound on each render.
57
+
58
+ Inside `render` (zero-arity as of 0.4.0), `self` is the component, so the
59
+ DSL is bareword: HTML is authored as `div`, custom elements as
60
+ `tag(:custom_element)`, child components as `component`, forms as `form_for`,
61
+ styles as `styles.name(variant)` or `styles[:name]`, resources as
62
+ `resources[:name]`, and routes as `routes.user_path(id)`. Component state is
63
+ explicitly read with `state[:name]` or `state.fetch(:name)`.
64
+
65
+ Tag and helper names (~46 words) are reserved inside component classes:
66
+ defining one raises `DSLCollisionError` at class-definition time
67
+ (`method_added`) or at first mount (`validate_dsl_conflicts!`, which also
68
+ covers `attr_*` on mruby and included modules). `allow_dsl_override :name`
69
+ opts out per class; the shadowed element stays reachable via `tag(:name)`.
70
+ Two caveats are inherent to barewords: `p` builds a `<p>` element (use
71
+ `puts x.inspect` for debugging; non-Hash arguments raise with a hint), and
72
+ a local variable named after a tag shadows the zero-paren call form (write
73
+ `option()` or rename the local). Procs handed to ANOTHER component --
74
+ ErrorBoundary's `fallback:` -- run under that component's cursor and
75
+ therefore receive an explicit view context instead of barewords.
76
+
77
+ Style definitions are bareword too: the class-level `styles do ... end`
78
+ block runs on a BasicObject cleanroom builder, so any name (including
79
+ `display`, `hash`, ...) defines a style identically on mruby and CRuby.
80
+ Computed values need the explicit form `styles { |css| css.define(...) }`.
81
+ Unknown style lookups raise instead of returning an empty class string.
82
+
83
+ Component children are ordinary VDOM children stored on
84
+ `VDOM::Component#children`; there is no delayed `children_block` prop. This keeps
85
+ SSR, diffing, ErrorBoundary rendering, and hydration on the same data model.
53
86
 
54
87
  ## `lib/` Rails integration
55
88
 
56
- - `compiler.rb` -- runs the vendored `picorbc` (WebAssembly, via Node.js) to
89
+ - `compiler.rb` -- runs the vendored `mrbc` (WebAssembly, via Node.js) to
57
90
  compile `app/funicular/**/*.rb` (models, then stores, then components, then
58
91
  initializers) into a single `app/assets/builds/app.mrb`. `-g` is added in
59
92
  development for debug symbols.
@@ -73,25 +106,42 @@ with `Patcher`. Event handlers are native DOM listeners, re-bound on each render
73
106
  ## Vendored artifacts
74
107
 
75
108
  `rake copy_wasm` (run by `rake build`) copies the PicoRuby.wasm runtime and the
76
- `picorbc` compiler from the sibling `mrbgems/picoruby-wasm/npm/` directory into
109
+ `mrbc` compiler from the sibling `mrbgems/picoruby-wasm/npm/` directory into
77
110
  `lib/funicular/vendor/`:
78
111
 
79
112
  - `vendor/picoruby/dist/` -- production runtime build
80
113
  - `vendor/picoruby/debug/` -- development runtime build (debug symbols)
81
- - `vendor/picorbc/` -- the mruby compiler (run through Node.js)
114
+ - `vendor/mrbc/` -- the mruby compiler (run through Node.js)
82
115
 
83
116
  Because `copy_wasm` reads sibling directories inside the picoruby repository, it
84
117
  only works from within that checkout -- see Development below.
85
118
 
119
+ ## JavaScript interop contract
120
+
121
+ As of picoruby commit 9e69333f, `JS::Object` inherits `BasicObject` instead of
122
+ `Object`. Consequences for framework code:
123
+
124
+ - Dot access on JS values is reliable for names Kernel used to shadow
125
+ (`hash`, `send`, `open`, `class`, `method`, ...): they now reach the JS side
126
+ via `method_missing`.
127
+ - The Ruby protocol predicates `nil?`, `is_a?`, `kind_of?`, `instance_of?`, and
128
+ `respond_to?` are defined in C on `JS::Object` (a `?` suffix is illegal in a
129
+ JS identifier, so they can never shadow a JS property). `respond_to?` does a
130
+ real method-table lookup only; it does not report JS properties.
131
+ - Any other name ending in `?` or `!` raises `NoMethodError` instead of being
132
+ forwarded to JS, so typos fail loudly rather than silently returning nil.
133
+ - `==`, `to_s`, `inspect`, `[]`, `[]=`, `to_a`, and `typeof` are defined
134
+ directly on `JS::Object` and behave as before.
135
+
86
136
  ## Server-side rendering, briefly
87
137
 
88
138
  For SSR the `mrblib/` framework is loaded into the Rails process under CRuby.
89
139
  `Funicular::SSR.render(path:, state:)` resolves the path against the routes in
90
- `app/funicular/initializer.rb`, builds the component's VDOM, and serializes it
91
- with `HTMLSerializer`. The state is also embedded as `window.__FUNICULAR_STATE__`
92
- so the browser can hydrate the markup rather than rebuild it. Keep `render`
93
- deterministic and free of browser-only calls so the same code is safe on both
94
- sides.
140
+ `app/funicular/initializer.rb`, builds a `Runtime` around that router, builds the
141
+ component's VDOM, and serializes it with `HTMLSerializer`. The state is also
142
+ embedded as `window.__FUNICULAR_STATE__` so the browser can hydrate the markup
143
+ rather than rebuild it. Keep `render` deterministic and free of browser-only
144
+ calls so the same code is safe on both sides.
95
145
 
96
146
  ## Development
97
147