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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +111 -0
- data/README.md +2 -2
- data/Rakefile +23 -15
- data/demo/test_chartjs.html +8 -8
- data/demo/test_component.html +8 -8
- data/demo/test_error_boundary.html +8 -5
- data/demo/test_router.html +11 -11
- data/demo/tic-tac-toe.html +4 -4
- data/docs/architecture.md +80 -30
- data/lib/funicular/compiler.rb +13 -13
- data/lib/funicular/helpers/picoruby_helper.rb +24 -1
- data/lib/funicular/ssr/runtime.rb +3 -0
- data/lib/funicular/ssr.rb +2 -1
- data/lib/funicular/testing/node_runner.rb +1 -1
- data/lib/funicular/vendor/mrbc/VERSION +1 -0
- data/lib/funicular/vendor/{picorbc/picorbc.js → mrbc/mrbc.js} +626 -486
- data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/VERSION +1 -1
- data/lib/funicular/vendor/picoruby/debug/init.iife.js +19 -4
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +675 -449
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/init.iife.js +19 -4
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -2
- data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -1
- data/lib/funicular/vendor/picoruby-test-node/picoruby.js +860 -567
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -1
- data/lib/funicular/version.rb +1 -1
- data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +25 -25
- data/lib/tasks/funicular.rake +1 -1
- data/minitest/commands_routes_test.rb +97 -0
- data/minitest/compiler_test.rb +195 -0
- data/minitest/configuration_test.rb +64 -0
- data/minitest/dsl_test.rb +237 -0
- data/minitest/fixtures/funicular_app/components/greeting_component.rb +2 -2
- data/minitest/funicular_test.rb +28 -2
- data/minitest/middleware_test.rb +154 -0
- data/minitest/picoruby_helper_test.rb +139 -0
- data/minitest/route_parser_test.rb +139 -0
- data/minitest/schema_test.rb +23 -0
- data/minitest/sig_tags_test.rb +30 -0
- data/minitest/ssr_test.rb +57 -0
- data/minitest/support/rails_stub.rb +59 -0
- data/minitest/test_helper.rb +20 -0
- data/minitest/testing_test.rb +267 -0
- data/minitest/view_context_test.rb +101 -0
- data/mrblib/0_tags.rb +62 -0
- data/mrblib/component.rb +255 -244
- data/mrblib/debug.rb +7 -6
- data/mrblib/differ.rb +3 -1
- data/mrblib/error_boundary.rb +11 -13
- data/mrblib/form_builder.rb +28 -24
- data/mrblib/funicular.rb +2 -1
- data/mrblib/html_serializer.rb +14 -13
- data/mrblib/http.rb +12 -1
- data/mrblib/patcher.rb +12 -9
- data/mrblib/router.rb +9 -5
- data/mrblib/runtime.rb +28 -0
- data/mrblib/styles.rb +107 -21
- data/mrblib/vdom.rb +90 -9
- data/mrblib/view_context.rb +106 -0
- data/sig/component.rbs +47 -84
- data/sig/form_builder.rbs +2 -1
- data/sig/html_serializer.rbs +2 -1
- data/sig/http.rbs +1 -0
- data/sig/patcher.rbs +1 -1
- data/sig/router.rbs +2 -13
- data/sig/runtime.rbs +13 -0
- data/sig/styles.rbs +19 -7
- data/sig/tags.rbs +54 -0
- data/sig/vdom.rbs +11 -2
- data/sig/view_context.rbs +60 -0
- metadata +22 -5
- data/lib/funicular/vendor/picorbc/VERSION +0 -1
- data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5816e8e43d54e6bec4b6bb8a06da6165ab138e549cdbad649d2acba002a5fec4
|
|
4
|
+
data.tar.gz: 51ed5ca984f12669bfd1587bf384ea685c54e2d03dff9552d3cc49485d29081f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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:
|
|
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
|
|
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)
|
|
70
|
+
# 2) mrbc compiler (node CLI, run by Funicular::Compiler)
|
|
63
71
|
# ------------------------------------------------------------------
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
abort "Missing #{
|
|
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
|
-
|
|
69
|
-
|
|
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(
|
|
72
|
-
FileUtils.mkdir_p(
|
|
73
|
-
|
|
74
|
-
src_file = File.join(
|
|
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(
|
|
84
|
+
FileUtils.copy_file(src_file, File.join(mrbc_dest, fname))
|
|
77
85
|
end
|
|
78
|
-
File.chmod(0755, File.join(
|
|
79
|
-
File.write(File.join(
|
|
80
|
-
puts " copied
|
|
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
|
data/demo/test_chartjs.html
CHANGED
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
return
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
-
current_data = state
|
|
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
|
|
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
|
|
84
|
-
puts "next_data_set: Changing index from #{state
|
|
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
|
|
91
|
-
puts "prev_data_set: Changing index from #{state
|
|
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
|
|
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
|
|
132
|
+
current = state[:data_sets][state[:month_index]]
|
|
133
133
|
"Current data: #{current[:labels].join(', ')}"
|
|
134
134
|
end
|
|
135
135
|
end
|
data/demo/test_component.html
CHANGED
|
@@ -40,26 +40,26 @@
|
|
|
40
40
|
|
|
41
41
|
def increment(event)
|
|
42
42
|
event.preventDefault if event
|
|
43
|
-
patch(count: state
|
|
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
|
|
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
|
|
53
|
+
h2 { "Counter: #{state[:count]}" }
|
|
54
54
|
div(id: "status") do
|
|
55
|
-
"Mounted: #{state
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
data/demo/test_router.html
CHANGED
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
input(
|
|
72
72
|
type: 'text',
|
|
73
73
|
placeholder: 'Username',
|
|
74
|
-
value: state
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
232
|
+
"This page demonstrates dynamic routing with params[:id] = #{state[:user_id]}"
|
|
233
233
|
end
|
|
234
234
|
end
|
|
235
235
|
end
|
data/demo/tic-tac-toe.html
CHANGED
|
@@ -96,8 +96,8 @@
|
|
|
96
96
|
end
|
|
97
97
|
|
|
98
98
|
def handle_play(i)
|
|
99
|
-
history = state
|
|
100
|
-
current_move = state
|
|
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
|
|
120
|
-
current_move = state
|
|
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)
|
|
30
|
-
|
|
31
|
-
| `funicular.rb`
|
|
32
|
-
| `
|
|
33
|
-
| `
|
|
34
|
-
| `
|
|
35
|
-
| `
|
|
36
|
-
| `
|
|
37
|
-
| `
|
|
38
|
-
| `
|
|
39
|
-
| `
|
|
40
|
-
| `
|
|
41
|
-
| `
|
|
42
|
-
| `
|
|
43
|
-
| `
|
|
44
|
-
| `
|
|
45
|
-
| `
|
|
46
|
-
| `
|
|
47
|
-
| `
|
|
48
|
-
| `
|
|
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
|
|
52
|
-
with `Patcher`. Event handlers are native DOM listeners,
|
|
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 `
|
|
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
|
-
`
|
|
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/
|
|
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
|
|
91
|
-
with `HTMLSerializer`. The state is also
|
|
92
|
-
so the browser can hydrate the markup
|
|
93
|
-
deterministic and free of browser-only
|
|
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
|
|