ruflet_rails 0.0.13 → 0.0.14

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.
@@ -1,249 +1,52 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
3
4
  require "uri"
4
5
 
5
6
  module Ruflet
6
7
  module Rails
7
- # Managed webview navigation driver for ruflet_rails.
8
- #
9
- # Your existing web app is the body, rendered in a WebView. Navigation works
10
- # out of the box: a tiny JS bridge injected into each page intercepts link
11
- # clicks and proposes them to native, so every visit becomes a NATIVE screen
12
- # pushed onto the stack (with an automatic back button) — no per-link code.
13
- # The native AppBar title tracks each page's <title>, and you declare special
14
- # paths as data.
15
- #
16
- # Ruflet.run do |page|
17
- # Ruflet::Rails.native_app(
18
- # page,
19
- # start_url: "https://myapp.com",
20
- # title: "My App", # auto-updates from <title>
21
- # actions: -> { [icon_button("search", on_click: ->(_e) { ... })] },
22
- # navigation_bar: navigation_bar(destinations: [...]),
23
- #
24
- # # web content shown in a bottom sheet (auth, quick forms):
25
- # modal: ["/sign_in", "/sign_up", %r{/new\z}],
26
- #
27
- # # optional: override a path with a fully native screen:
28
- # native: { %r{\A/products/(\d+)\z} => ->(ctx) { product_screen(ctx.match[1]) } }
29
- # )
30
- # end
31
- #
32
- # Normal links just push a native webview screen (back returns). Paths listed
33
- # in `modal:` open as a bottom sheet. Paths in `native:` render your own UI.
34
- #
35
- # The bridge talks to native over the webview's console channel
36
- # (on_console_message), which works on iOS/Android/macOS. On platforms
37
- # without a native webview the body degrades to a plain frame.
8
+ # Managed native shell for a Rails WebView body.
38
9
  class NativeApp
39
- # Injected into every page: report the title, and turn same-origin link
40
- # clicks into native visit proposals (so they don't load in place).
41
- BRIDGE_JS = <<~JS
42
- (function () {
43
- function report(kind, value) { console.log("ruflet:" + kind + ":" + value); }
44
- report("title", document.title || "");
45
- if (window.__rufletBridgeBound) return;
46
- window.__rufletBridgeBound = true;
47
- document.addEventListener("click", function (e) {
48
- var a = e.target && e.target.closest ? e.target.closest("a[href]") : null;
49
- if (!a || a.target === "_blank") return;
50
- if (a.origin && a.origin !== location.origin) return; // external: leave it
51
- e.preventDefault();
52
- report("visit", a.href);
53
- }, true);
54
- })();
55
- JS
10
+ Screen = Struct.new(:url, :view, :webview, :loading, :body, :title_text, :appbar_spec, :appbar_signature,
11
+ :drawer, :end_drawer, :drawer_signature, :rail, :rail_signature, keyword_init: true)
56
12
 
57
- VISIT_PREFIX = "ruflet:visit:"
58
- TITLE_PREFIX = "ruflet:title:"
59
-
60
- Screen = Struct.new(:kind, :url, :view, :webview, :title_text, keyword_init: true)
61
- Context = Struct.new(:url, :path, :match, keyword_init: true)
62
-
63
- def initialize(page, start_url:, title: nil, actions: nil, navigation_bar: nil,
64
- bottom_appbar: nil, modal: [], native: {})
13
+ def initialize(page, start_url:, title: nil, actions: nil, navigation_bar: nil, bottom_appbar: nil,
14
+ loading: :shimmer)
65
15
  @page = page
66
16
  @start_url = start_url.to_s
67
17
  @title = title.to_s
68
18
  @actions = actions
69
19
  @navigation_bar = navigation_bar
70
20
  @bottom_appbar = bottom_appbar
71
- @modal_patterns = Array(modal).map { |p| compile_pattern(p) }
72
- @native_rules = native.map { |pattern, builder| [compile_pattern(pattern), builder] }
21
+ @loading = loading
73
22
  @screens = []
74
- @modal_sheet = nil
23
+ @sheet = nil
24
+ @dialog = nil
25
+ @bottomnav_signature = nil
26
+ @bottomnav_spec = nil
27
+ @appbar_specs_by_url = {}
28
+ @drawer_spec = nil
29
+ @drawer_open_requested = false
75
30
  end
76
31
 
77
- def self.bridge_js = BRIDGE_JS
78
-
79
32
  def start
80
33
  @page.on_view_pop = ->(_event) { pop }
81
34
  push_webview(@start_url, root: true)
82
35
  self
83
36
  end
84
-
85
- private
86
-
87
- # --- Stack operations ---------------------------------------------------
88
-
89
- def push_webview(url, root: false)
90
- screen = Screen.new(kind: :webview, url: url.to_s)
91
- screen.title_text = Ruflet::UI::ControlFactory.build(:text, value: @title)
92
- screen.webview = build_webview(screen)
93
- screen.view = build_view(screen, root: root)
94
- @screens.push(screen)
95
- flush
96
- screen
97
- end
98
-
99
- def push_native(builder, ctx)
100
- view = builder.call(ctx)
101
- return unless view.is_a?(Ruflet::Control)
102
-
103
- @screens.push(Screen.new(kind: :native, url: ctx.url, view: view))
104
- flush
105
- end
106
-
107
- def pop
108
- return if @screens.size <= 1
109
-
110
- @screens.pop
111
- flush
112
- end
113
-
114
- def flush
115
- @page.views = @screens.map(&:view)
116
- @page.update
117
- end
118
-
119
- # --- Webview screen + native chrome ------------------------------------
120
-
121
- def build_webview(screen)
122
- Ruflet::UI::ControlFactory.build(
123
- :webview,
124
- url: screen.url,
125
- method: "get",
126
- expand: true,
127
- on_page_ended: ->(_event) { inject_bridge(screen) },
128
- on_console_message: ->(event) { handle_message(screen, message_of(event)) }
129
- )
130
- end
131
-
132
- def build_view(screen, root:)
133
- args = { route: root ? "/" : "/screen", controls: [screen.webview], appbar: build_appbar(screen) }
134
- if root
135
- args[:navigation_bar] = @navigation_bar unless @navigation_bar.nil?
136
- args[:bottom_appbar] = @bottom_appbar unless @bottom_appbar.nil?
137
- end
138
- Ruflet::UI::ControlFactory.build(:view, **args)
139
- end
140
-
141
- def build_appbar(screen)
142
- args = { title: screen.title_text }
143
- actions = resolve_actions
144
- args[:actions] = actions if actions
145
- Ruflet::UI::ControlFactory.build(:appbar, **args)
146
- end
147
-
148
- def resolve_actions
149
- @actions.respond_to?(:call) ? @actions.call : @actions
150
- end
151
-
152
- # --- Bridge ------------------------------------------------------------
153
-
154
- def inject_bridge(screen)
155
- screen.webview.run_javascript(BRIDGE_JS) if screen.webview.respond_to?(:run_javascript)
156
- rescue StandardError
157
- nil
158
- end
159
-
160
- def handle_message(screen, message)
161
- return if message.nil?
162
-
163
- if message.start_with?(TITLE_PREFIX)
164
- update_title(screen, message[TITLE_PREFIX.length..])
165
- elsif message.start_with?(VISIT_PREFIX)
166
- visit(message[VISIT_PREFIX.length..])
167
- end
168
- end
169
-
170
- def update_title(screen, value)
171
- return unless screen.title_text&.wire_id
172
-
173
- @page.update(screen.title_text, value: value.to_s)
174
- end
175
-
176
- # A proposed visit: native screen > modal sheet > push a webview screen.
177
- def visit(url)
178
- path = path_of(url)
179
- return if path.nil?
180
-
181
- builder, match = match_native(path)
182
- if builder
183
- push_native(builder, Context.new(url: url, path: path, match: match))
184
- elsif modal?(path)
185
- present_modal(url)
186
- else
187
- push_webview(url)
188
- end
189
- end
190
-
191
- # --- Modal (bottom sheet of web content) -------------------------------
192
-
193
- def present_modal(url)
194
- sheet_webview = Ruflet::UI::ControlFactory.build(:webview, url: url.to_s, method: "get", expand: true)
195
- @modal_sheet = Ruflet::UI::ControlFactory.build(
196
- :bottomsheet,
197
- open: true,
198
- dismissible: true,
199
- content: Ruflet::UI::ControlFactory.build(:container, height: 520, content: sheet_webview),
200
- on_dismiss: ->(_event) { @modal_sheet = nil }
201
- )
202
- @page.bottom_sheet = @modal_sheet
203
- @page.update
204
- end
205
-
206
- # --- Matching ----------------------------------------------------------
207
-
208
- def match_native(path)
209
- @native_rules.each do |pattern, builder|
210
- if pattern.is_a?(Regexp)
211
- m = pattern.match(path)
212
- return [builder, m] if m
213
- elsif pattern == path
214
- return [builder, nil]
215
- end
216
- end
217
- nil
218
- end
219
-
220
- def modal?(path)
221
- @modal_patterns.any? do |pattern|
222
- pattern.is_a?(Regexp) ? pattern.match?(path) : pattern == path
223
- end
224
- end
225
-
226
- def compile_pattern(pattern)
227
- return pattern if pattern.is_a?(Regexp)
228
-
229
- value = pattern.to_s
230
- value.start_with?("/") ? value : "/#{value}"
231
- end
232
-
233
- def path_of(url)
234
- URI.parse(url.to_s).path
235
- rescue URI::InvalidURIError
236
- nil
237
- end
238
-
239
- def message_of(event)
240
- data = event.respond_to?(:data) ? event.data : event
241
- return data["message"] || data[:message] if data.is_a?(Hash)
242
-
243
- data
244
- end
245
37
  end
38
+ end
39
+ end
246
40
 
41
+ require_relative "native_app/html_adapter"
42
+ require_relative "native_app/customization"
43
+ require_relative "native_app/shell"
44
+ require_relative "native_app/navigation"
45
+ require_relative "native_app/actions"
46
+ require_relative "native_app/overlays"
47
+
48
+ module Ruflet
49
+ module Rails
247
50
  module_function
248
51
 
249
52
  # Start a managed webview app. See NativeApp.
@@ -74,7 +74,7 @@ module Ruflet
74
74
 
75
75
  def entrypoint
76
76
  @entrypoint_option || @app_block ||
77
- raise(ArgumentError, "web_app requires one of view:, app_file:, or a block")
77
+ raise(ArgumentError, "web_app requires one of app_file: or a block")
78
78
  end
79
79
 
80
80
  # The web build must NOT live under public/, or Rails' static
@@ -3,11 +3,6 @@
3
3
  module Ruflet
4
4
  module Rails
5
5
  class Railtie < ::Rails::Railtie
6
- generators do
7
- require "ruflet/rails/generator_hooks"
8
- Ruflet::Rails::GeneratorHooks.install!
9
- end
10
-
11
6
  # Make ruflet_frame and friends available in every .erb template.
12
7
  initializer "ruflet_rails.view_helpers" do
13
8
  ActiveSupport.on_load(:action_view) do
@@ -15,18 +10,6 @@ module Ruflet
15
10
  end
16
11
  end
17
12
 
18
- # Ruflet components live under app/views so Rails will not discover them
19
- # by default. Add that directory as a Zeitwerk root and collapse its
20
- # organizational subdirectories, matching generated top-level constants
21
- # such as components/products/product_component.rb -> ProductComponent.
22
- initializer "ruflet_rails.components", before: :bootstrap_hook do |app|
23
- components = app.root.join("app/views/ruflet/components")
24
- next unless components.directory?
25
-
26
- app.autoloaders.main.push_dir(components)
27
- app.autoloaders.main.collapse(components.join("**"))
28
- end
29
-
30
13
  initializer "ruflet_rails.desktop_launcher", after: :load_config_initializers do |_app|
31
14
  next unless defined?(::Rails.root)
32
15
 
@@ -61,10 +44,10 @@ module Ruflet
61
44
  require "tempfile"
62
45
 
63
46
  exit_code = Dir.chdir(::Rails.root) do
64
- # If no ruflet.yaml on disk, generate one from Rails config so the
65
- # CLI has access to assets, services, and build options.
47
+ # Rails build metadata lives in config/initializers/ruflet.rb.
48
+ # Serialize it to the CLI's yaml shape for this build invocation.
66
49
  yaml_hash = cfg.to_ruflet_yaml_hash
67
- use_temp = yaml_hash.any? && !File.file?("ruflet.yaml") && !File.file?("ruflet.yml")
50
+ use_temp = yaml_hash.any?
68
51
 
69
52
  if use_temp
70
53
  Tempfile.create(["ruflet", ".yaml"], ::Rails.root) do |f|
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "erb"
4
+ require "json"
4
5
 
5
6
  module Ruflet
6
7
  module Rails
@@ -44,8 +45,212 @@ module Ruflet
44
45
  ruflet_html_safe(markup)
45
46
  end
46
47
 
48
+ # --- Native annotations ------------------------------------------------
49
+ #
50
+ # These emit plain HTML decorated with `ruflet-*` attributes. In a normal
51
+ # browser they render and behave as ordinary HTML; inside the Ruflet
52
+ # native shell (Ruflet::Rails.native_app) the HTML adapter reads the
53
+ # attributes and drives native navigation/chrome instead. They degrade
54
+ # gracefully — no JavaScript required for the web rendering.
55
+
56
+ # A header the native shell hides and re-renders as a native AppBar. Place
57
+ # action items inside with `ruflet-icon` (use ruflet_appbar_action).
58
+ #
59
+ # <%= ruflet_appbar "Inbox" do %>
60
+ # <%= ruflet_appbar_action "search", search_path %>
61
+ # <% end %>
62
+ def ruflet_appbar(title = nil, payload: {}, leading: nil, actions: nil, **attrs, &block)
63
+ appbar_payload = ruflet_stringify_keys(payload)
64
+ appbar_payload["leading"] = ruflet_stringify_keys(leading) unless leading.nil?
65
+ appbar_payload["actions"] = ruflet_stringify_keys(actions) unless actions.nil?
66
+ data = { "data-ruflet-appbar" => appbar_payload.empty? ? "" : ruflet_json(appbar_payload),
67
+ "data-ruflet-title" => title,
68
+ "hidden" => true }
69
+ ruflet_build_tag("header", ruflet_capture(&block), data.merge(ruflet_dash_attrs(attrs)))
70
+ end
71
+
72
+ # An AppBar action button (icon taps navigate).
73
+ def ruflet_appbar_action(icon, href = nil, nav: :push, payload: {}, **attrs)
74
+ payload = ruflet_compact_hash({ "icon" => icon, "action" => nav.to_s }.merge(ruflet_stringify_keys(payload)))
75
+ data = { "data-ruflet-icon" => ruflet_json(payload), "href" => href }
76
+ ruflet_build_tag("a", "", data.merge(ruflet_dash_attrs(attrs)))
77
+ end
78
+
79
+ # A nav the native shell hides and re-renders as a native bottom
80
+ # NavigationBar. Items are `ruflet_nav_item` links.
81
+ #
82
+ # <%= ruflet_bottom_nav do %>
83
+ # <%= ruflet_nav_item "Home", root_path, icon: "house", selected: true %>
84
+ # <%= ruflet_nav_item "Profile", profile_path, icon: "person" %>
85
+ # <% end %>
86
+ def ruflet_bottom_nav(name: "bottom-navigation", payload: {}, **attrs, &block)
87
+ nav_payload = ruflet_stringify_keys(payload)
88
+ ruflet_build_tag("nav", ruflet_capture(&block), {
89
+ "data-ruflet-tabs" => nav_payload.empty? ? name : ruflet_json(nav_payload),
90
+ "hidden" => true
91
+ }.merge(ruflet_dash_attrs(attrs)))
92
+ end
93
+
94
+ # A destination inside ruflet_bottom_nav.
95
+ def ruflet_nav_item(label, href, icon:, selected: false, color: nil, size: 24, payload: {}, **attrs)
96
+ item_payload = ruflet_stringify_keys(payload)
97
+ data = {
98
+ "href" => href,
99
+ "data-ruflet-icon" => ruflet_json(ruflet_compact_hash(item_payload.merge(icon: icon, label: label,
100
+ color: color, size: size)))
101
+ }
102
+ data["data-ruflet-selected"] = "true" if selected
103
+ ruflet_build_tag("a", label, data.merge(ruflet_dash_attrs(attrs)))
104
+ end
105
+
106
+ # A drawer the native shell hides and re-renders as a native
107
+ # NavigationDrawer. Items are `ruflet_drawer_item` links.
108
+ #
109
+ # <%= ruflet_drawer do %>
110
+ # <%= ruflet_drawer_item "Home", root_path, icon: "home" %>
111
+ # <%= ruflet_drawer_item "Settings", settings_path, icon: "settings", nav: :push %>
112
+ # <% end %>
113
+ def ruflet_drawer(action: :root, payload: {}, **attrs, &block)
114
+ ruflet_build_tag("nav", ruflet_capture(&block), {
115
+ "data-ruflet-drawer" => ruflet_json(ruflet_compact_hash(ruflet_stringify_keys(payload).merge(action: action.to_s))),
116
+ "hidden" => true
117
+ }.merge(ruflet_dash_attrs(attrs)))
118
+ end
119
+
120
+ # A destination inside ruflet_drawer.
121
+ def ruflet_drawer_item(label, href, icon:, selected: false, nav: nil, payload: {}, **attrs)
122
+ item_payload = ruflet_stringify_keys(payload)
123
+ data = {
124
+ "href" => href,
125
+ "data-ruflet-icon" => ruflet_json(ruflet_compact_hash(item_payload.merge(icon: icon, label: label,
126
+ action: nav&.to_s)))
127
+ }
128
+ data["data-ruflet-selected"] = "true" if selected
129
+ ruflet_build_tag("a", label, data.merge(ruflet_dash_attrs(attrs)))
130
+ end
131
+
132
+ # Desktop/tablet navigation the native shell hides and re-renders as a
133
+ # native NavigationRail beside the WebView body.
134
+ #
135
+ # <%= ruflet_navigation_rail extended: true do %>
136
+ # <%= ruflet_rail_item "Home", root_path, icon: "home" %>
137
+ # <%= ruflet_rail_item "Inbox", inbox_path, icon: "mail" %>
138
+ # <% end %>
139
+ def ruflet_navigation_rail(action: :root, extended: nil, label_type: nil, breakpoint: nil, payload: {}, **attrs, &block)
140
+ payload = ruflet_compact_hash(ruflet_stringify_keys(payload).merge(action: action.to_s, extended: extended,
141
+ label_type: label_type, breakpoint: breakpoint))
142
+ ruflet_build_tag("nav", ruflet_capture(&block), {
143
+ "data-ruflet-rail" => ruflet_json(payload),
144
+ "hidden" => true
145
+ }.merge(ruflet_dash_attrs(attrs)))
146
+ end
147
+
148
+ alias ruflet_rail ruflet_navigation_rail
149
+
150
+ # A destination inside ruflet_navigation_rail.
151
+ def ruflet_rail_item(label, href, icon:, selected: false, nav: nil, payload: {}, **attrs)
152
+ item_payload = ruflet_stringify_keys(payload)
153
+ data = {
154
+ "href" => href,
155
+ "data-ruflet-icon" => ruflet_json(ruflet_compact_hash(item_payload.merge(icon: icon, label: label,
156
+ action: nav&.to_s)))
157
+ }
158
+ data["data-ruflet-selected"] = "true" if selected
159
+ ruflet_build_tag("a", label, data.merge(ruflet_dash_attrs(attrs)))
160
+ end
161
+
162
+ # Native platform services from ERB. These are normal HTML elements in a
163
+ # browser and native service calls inside Ruflet::Rails.native_app.
164
+ def ruflet_share_link(label, href = "#", text: nil, title: nil, subject: nil, url: nil, files: nil, **attrs)
165
+ payload = ruflet_compact_hash(component: "share", text: text, title: title, subject: subject,
166
+ url: url || href, files: files)
167
+ ruflet_action_tag("a", label, payload, { "href" => href }.merge(ruflet_dash_attrs(attrs)))
168
+ end
169
+
170
+ def ruflet_copy_button(label, text:, toast: "Copied", haptic: true, **attrs)
171
+ payload = ruflet_compact_hash(component: "clipboard", text: text, toast: toast, haptic: haptic)
172
+ ruflet_action_tag("button", label, payload, { "type" => "button" }.merge(ruflet_dash_attrs(attrs)))
173
+ end
174
+
175
+ def ruflet_launch_link(label, href, mode: nil, **attrs)
176
+ payload = ruflet_compact_hash(component: "url_launcher", url: href, mode: mode)
177
+ ruflet_action_tag("a", label, payload, { "href" => href }.merge(ruflet_dash_attrs(attrs)))
178
+ end
179
+
180
+ def ruflet_haptic_button(label, style: "selection", **attrs)
181
+ payload = ruflet_compact_hash(component: "haptic", style: style)
182
+ ruflet_action_tag("button", label, payload, { "type" => "button" }.merge(ruflet_dash_attrs(attrs)))
183
+ end
184
+
47
185
  private
48
186
 
187
+ def ruflet_action_tag(name, content, payload, attrs)
188
+ attrs["data-ruflet-action"] = ruflet_json(ruflet_stringify_keys(payload))
189
+ ruflet_build_tag(name, content, attrs)
190
+ end
191
+
192
+ # Capture block content via ActionView when available (real ERB), else
193
+ # fall back to the block's return value (standalone use / tests).
194
+ def ruflet_capture(&block)
195
+ return nil unless block
196
+
197
+ respond_to?(:capture) ? capture(&block) : block.call
198
+ end
199
+
200
+ # Build a tag from a name, inner content, and an attribute hash. nil/false
201
+ # attribute values are dropped; "" is kept (a bare boolean attribute).
202
+ def ruflet_build_tag(name, content, attributes)
203
+ markup = +"<#{name}"
204
+ attributes.each do |key, value|
205
+ next if value.nil? || value == false
206
+
207
+ markup << %( #{key}="#{ERB::Util.html_escape(value)}")
208
+ end
209
+ markup << ">"
210
+ markup << content.to_s if content
211
+ markup << "</#{name}>"
212
+ ruflet_html_safe(markup)
213
+ end
214
+
215
+ # Turn extra helper kwargs (data_role:) into dashed attribute keys
216
+ # (data-role), mirroring ruflet_frame.
217
+ def ruflet_dash_attrs(attrs)
218
+ attrs.each_with_object({}) do |(key, value), out|
219
+ if value.is_a?(Hash)
220
+ value.each do |nested_key, nested_value|
221
+ out["#{key}-#{nested_key}".to_s.tr("_", "-")] = nested_value.nil? ? nil : nested_value.to_s
222
+ end
223
+ else
224
+ out[key.to_s.tr("_", "-")] = value
225
+ end
226
+ end
227
+ end
228
+
229
+ def ruflet_compact_hash(hash)
230
+ hash.each_with_object({}) do |(key, value), out|
231
+ next if value.nil?
232
+
233
+ out[key] = value
234
+ end
235
+ end
236
+
237
+ def ruflet_stringify_keys(value)
238
+ case value
239
+ when Hash
240
+ value.each_with_object({}) do |(key, nested), out|
241
+ out[key.to_s] = ruflet_stringify_keys(nested)
242
+ end
243
+ when Array
244
+ value.map { |nested| ruflet_stringify_keys(nested) }
245
+ else
246
+ value
247
+ end
248
+ end
249
+
250
+ def ruflet_json(value)
251
+ JSON.generate(value)
252
+ end
253
+
49
254
  def ruflet_css_dimension(value)
50
255
  value.is_a?(Numeric) ? "#{value}px" : value.to_s
51
256
  end
data/lib/ruflet/rails.rb CHANGED
@@ -20,18 +20,6 @@ module Ruflet
20
20
  yield config
21
21
  end
22
22
 
23
- # Flet-style routed navigation stack for complex multi-screen apps. Wires
24
- # up on_route_change / on_view_pop and starts at the current route. See
25
- # Ruflet::Rails::RouteStack.
26
- #
27
- # Ruflet::Rails.routed(page) do |route, nav|
28
- # nav.push(home_view)
29
- # nav.push(store_view) if route == "/store"
30
- # end
31
- def routed(page, &builder)
32
- RouteStack.new(page, &builder).start
33
- end
34
-
35
23
  def sessions
36
24
  @sessions ||= SessionRegistry.new
37
25
  end
@@ -40,30 +28,24 @@ module Ruflet
40
28
  sessions.broadcast(&block)
41
29
  end
42
30
 
43
- # WebSocket endpoint for native mobile/desktop clients. The developer
44
- # declares the entry the same way they declare a web mount — the screens
45
- # the app shows live in dev code, never in framework auto-discovery:
31
+ # WebSocket endpoint for native mobile/desktop clients. The developer owns
32
+ # the Ruflet entrypoint explicitly; there is no Rails component discovery:
46
33
  #
47
34
  # # a standalone Ruflet app file (Ruflet.run/MyApp.new.run), per session:
48
35
  # match "/ws", to: Ruflet::Rails.endpoint(app_file: Rails.root.join("app/ruflet/main.rb")), via: :all
49
36
  #
50
- # # a single component/view class (resolved lazily, so reloading works):
51
- # match "/ws", to: Ruflet::Rails.endpoint(view: "ProductComponent"), via: :all
52
- #
53
37
  # # a custom block:
54
38
  # match "/ws", to: Ruflet::Rails.endpoint { |page| MyHome.render(page) }, via: :all
55
39
  #
56
- # One of view:, app_file:, or a block is required — there is no
57
- # auto-discovery fallback.
58
- def endpoint(view: nil, app_file: nil, &block)
59
- sources = [view, app_file, block].compact
60
- raise ArgumentError, "endpoint accepts only one of view:, app_file:, or a block" if sources.length > 1
61
- raise ArgumentError, "endpoint requires one of view:, app_file:, or a block" if sources.empty?
40
+ # One of app_file: or a block is required.
41
+ def endpoint(app_file: nil, &block)
42
+ sources = [app_file, block].compact
43
+ raise ArgumentError, "endpoint accepts only one of app_file: or a block" if sources.length > 1
44
+ raise ArgumentError, "endpoint requires one of app_file: or a block" if sources.empty?
62
45
 
63
46
  return Protocol::Runner.new.build_app_endpoint(file_path: app_file) if app_file
64
47
 
65
- entry = block || web_app_entrypoint(view: view)
66
- Protocol::Runner.new(&entry).build_endpoint
48
+ Protocol::Runner.new(&block).build_endpoint
67
49
  end
68
50
 
69
51
  # Shorthand for a standalone app-file endpoint.
@@ -78,49 +60,34 @@ module Ruflet
78
60
  # answers the Ruflet WebSocket on the same path. Routes stay routing-only;
79
61
  # UI code lives in dev files:
80
62
  #
81
- # # a single view class (resolved lazily, so reloading works):
82
- # mount Ruflet::Rails.web_app(view: "CounterView"), at: "/myfrontend"
83
- #
84
63
  # # a standalone Ruflet app file (MyApp.new.run), loaded per session:
85
64
  # mount Ruflet::Rails.web_app(app_file: "app/ruflet/showcase/main.rb"), at: "/showcase"
86
65
  #
87
66
  # # a custom block:
88
67
  # mount Ruflet::Rails.web_app { |page| MyHome.render(page) }, at: "/app"
89
68
  #
90
- # One of view:, app_file:, or a block is required — there is no
91
- # auto-discovery fallback.
92
- def web_app(view: nil, app_file: nil, build_dir: nil, &app_block)
93
- sources = [view, app_file, app_block].compact
94
- raise ArgumentError, "web_app accepts only one of view:, app_file:, or a block" if sources.length > 1
95
- raise ArgumentError, "web_app requires one of view:, app_file:, or a block" if sources.empty?
69
+ # One of app_file: or a block is required.
70
+ def web_app(app_file: nil, build_dir: nil, &app_block)
71
+ sources = [app_file, app_block].compact
72
+ raise ArgumentError, "web_app accepts only one of app_file: or a block" if sources.length > 1
73
+ raise ArgumentError, "web_app requires one of app_file: or a block" if sources.empty?
96
74
 
97
75
  Protocol::WebApp.new(
98
76
  build_dir: build_dir,
99
- entrypoint: web_app_entrypoint(view: view, app_file: app_file),
77
+ entrypoint: web_app_entrypoint(app_file: app_file),
100
78
  &app_block
101
79
  )
102
80
  end
103
81
 
104
- def web_app_entrypoint(view: nil, app_file: nil)
105
- if view
106
- lambda do |page|
107
- view_class_for(view).render(page)
108
- end
109
- elsif app_file
110
- absolute = app_file.to_s
111
- lambda do |page, env|
112
- loaded = Protocol::MobileLoader.new(File.expand_path(absolute)).load!
113
- entry = loaded[:entrypoint]
114
- entry.arity == 1 ? entry.call(page) : entry.call(page, env)
115
- end
116
- end
117
- end
82
+ def web_app_entrypoint(app_file: nil)
83
+ return unless app_file
118
84
 
119
- # Resolved lazily on each session so Rails code reloading picks up edits.
120
- def view_class_for(view)
121
- return view if view.is_a?(Class)
122
-
123
- view.to_s.constantize
85
+ absolute = app_file.to_s
86
+ lambda do |page, env|
87
+ loaded = Protocol::MobileLoader.new(File.expand_path(absolute)).load!
88
+ entry = loaded[:entrypoint]
89
+ entry.arity == 1 ? entry.call(page) : entry.call(page, env)
90
+ end
124
91
  end
125
92
 
126
93
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruflet
4
- VERSION = "0.0.13" unless const_defined?(:VERSION)
4
+ VERSION = "0.0.14" unless const_defined?(:VERSION)
5
5
  end