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,342 @@
1
+ # A lazy, chainable query over one local table (docs/local_database.md,
2
+ # "Querying"). Chain builders (where/order/limit/offset) each return a NEW
3
+ # Relation; SQL executes once, in a materializer.
4
+ #
5
+ # The `model` handed to the constructor is the query's metadata + row
6
+ # factory. Relation calls exactly these methods on it:
7
+ #
8
+ # table_name -> String
9
+ # local_columns -> Hash[String => Symbol] (column name -> declared type)
10
+ # local_db -> handle responding to execute(sql, binds) -> rows
11
+ # replica? -> bool (delete_all guard)
12
+ # build_from_local -> (Hash) -> model instance (attrs already decoded)
13
+ # local_table_changed -> void; called after a framework-managed write
14
+ # (delete_all here) so the model can fire change
15
+ # events and schedule snapshot persistence
16
+ #
17
+ # Identifiers in hash conditions and order() are validated against
18
+ # local_columns and double-quoted; values cross into SQL through
19
+ # Funicular::DB::Codec. Raw SQL fragments pass through untouched, their
20
+ # binds encoded by value (Codec.encode_bind).
21
+
22
+ module Funicular
23
+ class Relation
24
+ # The trailing arguments are internal: chain builders use them to spawn
25
+ # derived relations. Application code passes only `model`.
26
+ def initialize(model, where_sql = [], where_binds = [], order_sql = [],
27
+ limit = nil, offset = nil)
28
+ @model = model
29
+ @where_sql = where_sql
30
+ @where_binds = where_binds
31
+ @order_sql = order_sql
32
+ @limit = limit
33
+ @offset = offset
34
+ end
35
+
36
+ # ---- chain builders --------------------------------------------------
37
+
38
+ # where(title: "x") equality (nil -> IS NULL)
39
+ # where(id: [1, 2]) IN (empty array -> WHERE 1=0)
40
+ # where(id: 1..9) BETWEEN (exclusive end -> >= AND <)
41
+ # where("title LIKE ?", "a%") raw fragment with placeholders
42
+ # Multiple where calls AND together.
43
+ def where(conditions = nil, *binds)
44
+ w = @where_sql.dup
45
+ b = @where_binds.dup
46
+ if conditions.is_a?(Hash)
47
+ keys = conditions.keys
48
+ i = 0
49
+ while i < keys.size
50
+ append_condition(w, b, keys[i], conditions[keys[i]])
51
+ i += 1
52
+ end
53
+ elsif conditions.is_a?(String)
54
+ w << "(#{conditions})"
55
+ i = 0
56
+ while i < binds.size
57
+ b << Funicular::DB::Codec.encode_bind(binds[i])
58
+ i += 1
59
+ end
60
+ else
61
+ raise ArgumentError,
62
+ "where expects a Hash or a SQL fragment String, got #{conditions.inspect}"
63
+ end
64
+ Relation.new(@model, w, b, @order_sql, @limit, @offset)
65
+ end
66
+
67
+ # order(:created_at) ASC
68
+ # order(created_at: :desc)
69
+ # order(:pinned, created_at: :desc) multiple keys
70
+ def order(*args)
71
+ if args.empty?
72
+ raise ArgumentError, "order requires at least one column"
73
+ end
74
+ o = @order_sql.dup
75
+ i = 0
76
+ while i < args.size
77
+ arg = args[i]
78
+ if arg.is_a?(Hash)
79
+ keys = arg.keys
80
+ j = 0
81
+ while j < keys.size
82
+ o << order_term(keys[j], arg[keys[j]])
83
+ j += 1
84
+ end
85
+ else
86
+ o << order_term(arg, :asc)
87
+ end
88
+ i += 1
89
+ end
90
+ Relation.new(@model, @where_sql, @where_binds, o, @limit, @offset)
91
+ end
92
+
93
+ def limit(n)
94
+ Relation.new(@model, @where_sql, @where_binds, @order_sql,
95
+ slice_arg(n, "limit"), @offset)
96
+ end
97
+
98
+ def offset(n)
99
+ Relation.new(@model, @where_sql, @where_binds, @order_sql,
100
+ @limit, slice_arg(n, "offset"))
101
+ end
102
+
103
+ # ---- materializers ---------------------------------------------------
104
+
105
+ def to_a
106
+ rows = @model.local_db.execute(select_sql, @where_binds)
107
+ cols = @model.local_columns.keys
108
+ # @type var out: Array[untyped]
109
+ out = []
110
+ i = 0
111
+ while i < rows.size
112
+ out << @model.build_from_local(decode_row(cols, rows[i]))
113
+ i += 1
114
+ end
115
+ out
116
+ end
117
+
118
+ def each
119
+ list = to_a
120
+ i = 0
121
+ while i < list.size
122
+ yield list[i]
123
+ i += 1
124
+ end
125
+ list
126
+ end
127
+
128
+ # Instance or nil. Narrows the window to one row (never widens: a
129
+ # relation already limited to 0 stays empty).
130
+ def first
131
+ lim = @limit
132
+ lim = (lim.nil? || 1 <= lim) ? 1 : lim
133
+ Relation.new(@model, @where_sql, @where_binds, @order_sql,
134
+ lim, @offset).to_a[0]
135
+ end
136
+
137
+ # SELECT COUNT(*); on a limited/offset relation it counts the window
138
+ # (COUNT over a subquery), matching ActiveRecord.
139
+ def count
140
+ sql = if @limit || @offset
141
+ "SELECT COUNT(*) FROM (#{select_sql})"
142
+ else
143
+ "SELECT COUNT(*) FROM #{quoted_table}#{where_clause}"
144
+ end
145
+ single_value(sql)
146
+ end
147
+
148
+ def exists?
149
+ if @limit || @offset
150
+ 0 < count
151
+ else
152
+ sql = "SELECT 1 FROM #{quoted_table}#{where_clause} LIMIT 1"
153
+ !@model.local_db.execute(sql, @where_binds).empty?
154
+ end
155
+ end
156
+
157
+ def find(id)
158
+ record = find_by(id: id)
159
+ unless record
160
+ raise Funicular::RecordNotFound,
161
+ "Couldn't find #{model_label} with id=#{id}"
162
+ end
163
+ record
164
+ end
165
+
166
+ def find_by(conditions)
167
+ where(conditions).first
168
+ end
169
+
170
+ # Bulk delete, `storage :local` models only; returns the number of
171
+ # deleted rows. Raises on a relation carrying order/limit/offset (say
172
+ # what you mean with a plain condition).
173
+ def delete_all
174
+ if @model.replica?
175
+ raise Funicular::DB::ReplicaWriteError,
176
+ "delete_all is not available on replica models; the server owns " \
177
+ "replica rows (deletions reach the replica through destroy)"
178
+ end
179
+ if @limit || @offset || !@order_sql.empty?
180
+ raise ArgumentError,
181
+ "delete_all does not support order/limit/offset"
182
+ end
183
+ # RETURNING counts the deletions inside the one statement; reading
184
+ # SELECT changes() afterwards would race other Tasks writing on the
185
+ # same connection between the two calls.
186
+ rows = @model.local_db.execute(
187
+ "DELETE FROM #{quoted_table}#{where_clause} RETURNING \"id\"",
188
+ @where_binds)
189
+ count = rows.size
190
+ # Framework-managed writes must notify (docs, "Querying"); a delete
191
+ # that removed nothing changed nothing.
192
+ @model.local_table_changed if 0 < count
193
+ count
194
+ end
195
+
196
+ # The SELECT this relation will run (debugging aid; binds not inlined).
197
+ def to_sql
198
+ select_sql
199
+ end
200
+
201
+ # Internal (Component#watch): where change events for this
202
+ # relation's rows come from, as [role, table].
203
+ def __event_source
204
+ m = @model
205
+ [m.replica? ? :replica : :local, m.table_name]
206
+ end
207
+
208
+ # ---- internal --------------------------------------------------------
209
+
210
+ private
211
+
212
+ def append_condition(w, b, col, value)
213
+ name = validate_column(col)
214
+ q = quote(name)
215
+ type = @model.local_columns[name]
216
+ codec = Funicular::DB::Codec
217
+ if value.nil?
218
+ w << "#{q} IS NULL"
219
+ elsif value.is_a?(Array)
220
+ if value.empty?
221
+ w << "1=0"
222
+ else
223
+ # @type var marks: Array[String]
224
+ marks = []
225
+ i = 0
226
+ while i < value.size
227
+ marks << "?"
228
+ b << codec.encode(type, value[i])
229
+ i += 1
230
+ end
231
+ w << "#{q} IN (#{marks.join(", ")})"
232
+ end
233
+ elsif value.is_a?(Range)
234
+ b << codec.encode(type, value.begin)
235
+ b << codec.encode(type, value.end)
236
+ if value.exclude_end?
237
+ w << "(#{q} >= ? AND #{q} < ?)"
238
+ else
239
+ w << "#{q} BETWEEN ? AND ?"
240
+ end
241
+ else
242
+ w << "#{q} = ?"
243
+ b << codec.encode(type, value)
244
+ end
245
+ end
246
+
247
+ def order_term(col, dir)
248
+ d = dir.to_s.downcase
249
+ unless d == "asc" || d == "desc"
250
+ raise ArgumentError,
251
+ "order direction must be :asc or :desc, got #{dir.inspect}"
252
+ end
253
+ "#{quote(validate_column(col))} #{d == "asc" ? "ASC" : "DESC"}"
254
+ end
255
+
256
+ def validate_column(col)
257
+ name = col.to_s
258
+ unless @model.local_columns.has_key?(name)
259
+ raise ArgumentError,
260
+ "unknown column #{name.inspect} for table \"#{@model.table_name}\""
261
+ end
262
+ name
263
+ end
264
+
265
+ def quote(name)
266
+ "\"#{name}\""
267
+ end
268
+
269
+ def quoted_table
270
+ quote(@model.table_name)
271
+ end
272
+
273
+ def select_sql
274
+ cols = @model.local_columns.keys
275
+ # @type var quoted: Array[String]
276
+ quoted = []
277
+ i = 0
278
+ while i < cols.size
279
+ quoted << quote(cols[i])
280
+ i += 1
281
+ end
282
+ "SELECT #{quoted.join(", ")} FROM #{quoted_table}" \
283
+ "#{where_clause}#{order_clause}#{slice_clause}"
284
+ end
285
+
286
+ def where_clause
287
+ w = @where_sql
288
+ w.empty? ? "" : " WHERE #{w.join(" AND ")}"
289
+ end
290
+
291
+ def order_clause
292
+ o = @order_sql
293
+ o.empty? ? "" : " ORDER BY #{o.join(", ")}"
294
+ end
295
+
296
+ # LIMIT -1 OFFSET n is how SQLite spells offset-without-limit.
297
+ def slice_clause
298
+ lim = @limit
299
+ off = @offset
300
+ if lim
301
+ off ? " LIMIT #{lim} OFFSET #{off}" : " LIMIT #{lim}"
302
+ elsif off
303
+ " LIMIT -1 OFFSET #{off}"
304
+ else
305
+ ""
306
+ end
307
+ end
308
+
309
+ def slice_arg(n, label)
310
+ return nil if n.nil?
311
+ unless n.is_a?(Integer) && 0 <= n
312
+ raise ArgumentError, "#{label} must be a non-negative Integer, got #{n.inspect}"
313
+ end
314
+ n
315
+ end
316
+
317
+ def decode_row(cols, row)
318
+ codec = Funicular::DB::Codec
319
+ columns = @model.local_columns
320
+ # @type var attrs: Hash[String, untyped]
321
+ attrs = {}
322
+ i = 0
323
+ while i < cols.size
324
+ name = cols[i]
325
+ raw = row.is_a?(Hash) ? row[name] : row[i]
326
+ attrs[name] = codec.decode(columns[name], raw)
327
+ i += 1
328
+ end
329
+ attrs
330
+ end
331
+
332
+ def single_value(sql)
333
+ row = @model.local_db.execute(sql, @where_binds)[0]
334
+ row.is_a?(Hash) ? row.values[0] : row[0]
335
+ end
336
+
337
+ def model_label
338
+ m = @model
339
+ m.respond_to?(:name) ? m.name : m.table_name
340
+ end
341
+ end
342
+ end
data/mrblib/router.rb CHANGED
@@ -9,6 +9,7 @@ module Funicular
9
9
  @current_component = nil
10
10
  @current_path = nil
11
11
  @popstate_callback_id = nil
12
+ @beforeunload_callback_id = nil
12
13
  @url_helpers = Module.new
13
14
  @route_helpers = Object.new
14
15
  @route_helpers.extend(@url_helpers)
@@ -62,15 +63,38 @@ module Funicular
62
63
 
63
64
  @hydrate_initial = hydrate
64
65
 
65
- # Clean up existing listener if any (prevents duplicate registration)
66
+ # Clean up existing listeners if any (prevents duplicate registration)
66
67
  if @popstate_callback_id
67
68
  JS::Object.removeEventListener(@popstate_callback_id)
68
69
  @popstate_callback_id = nil
69
70
  end
71
+ if @beforeunload_callback_id
72
+ JS::Object.removeEventListener(@beforeunload_callback_id)
73
+ @beforeunload_callback_id = nil
74
+ end
70
75
 
71
- # Set up popstate listener
76
+ # Set up popstate listener. The history entry has already moved by
77
+ # the time popstate fires; when the current component's navigation
78
+ # guard vetoes leaving, push the guarded path back (the one thing
79
+ # popstate cannot cancel).
72
80
  @popstate_callback_id = JS.global.addEventListener('popstate') do |event|
73
- handle_route_change
81
+ if leave_allowed?
82
+ handle_route_change
83
+ elsif (guarded_path = @current_path)
84
+ # Local binding: the type checker does not narrow ivars
85
+ # through elsif.
86
+ JS.global.history.pushState(JS::Bridge.to_js({}), '', guarded_path)
87
+ end
88
+ end
89
+
90
+ # Reload / tab close: ask through the browser's native dialog when
91
+ # a guard is active. sync: the decision must be made on the JS
92
+ # event dispatch stack, so the guard must not suspend.
93
+ @beforeunload_callback_id = JS.global.addEventListener('beforeunload', sync: true) do |event|
94
+ if @current_component&.navigation_guard
95
+ event.preventDefault
96
+ event[:returnValue] = ''
97
+ end
74
98
  end
75
99
 
76
100
  # Handle initial route. Skip the default-route redirect when hydrating
@@ -90,16 +114,33 @@ module Funicular
90
114
  @popstate_callback_id = nil
91
115
  end
92
116
 
117
+ if @beforeunload_callback_id
118
+ JS::Object.removeEventListener(@beforeunload_callback_id)
119
+ @beforeunload_callback_id = nil
120
+ end
121
+
93
122
  unmount_current_component
94
123
  end
95
124
 
96
- # Navigate to a path programmatically using History API
125
+ # Navigate to a path programmatically using History API. A vetoing
126
+ # navigation guard on the current component cancels the navigation
127
+ # before any history change.
97
128
  def navigate(path)
129
+ return unless leave_allowed?
98
130
  JS.global.history.pushState(JS::Bridge.to_js({}), '', path)
99
131
  # Manually trigger route change because pushState doesn't fire popstate
100
132
  handle_route_change
101
133
  end
102
134
 
135
+ # Ask the current component's navigation guard whether leaving is
136
+ # allowed; a String from the guard prompts the user via
137
+ # Funicular.confirm. True when no component or no guard.
138
+ def leave_allowed?
139
+ message = @current_component&.navigation_guard
140
+ return true unless message
141
+ Funicular.confirm(message)
142
+ end
143
+
103
144
  # Get current path from location
104
145
  def current_location_path
105
146
  js_path_obj = JS.global.location.pathname
data/mrblib/styles.rb CHANGED
@@ -20,30 +20,67 @@ module Funicular
20
20
  end
21
21
  end
22
22
 
23
+ # String-like concatenation: styles.field + " col-span-2". No space
24
+ # is inserted and non-String operands raise TypeError, matching
25
+ # String#+ (a silent to_s would hide mistakes like `+ 123`); use |
26
+ # to join with a space.
27
+ # to_str is deliberately not defined: the mruby client's String#+
28
+ # never coerces (no implicit to_str call), so defining it on CRuby
29
+ # would let SSR accept "base " + styles.field while the browser
30
+ # raises. Both VMs reject the reversed form the same way instead.
31
+ def +(other)
32
+ case other
33
+ when StyleValue
34
+ StyleValue.new(@value + other.value)
35
+ when String
36
+ StyleValue.new(@value + other)
37
+ else
38
+ other = other #: untyped
39
+ raise TypeError, "no implicit conversion of #{other.class} into String"
40
+ end
41
+ end
42
+
23
43
  def to_s
24
44
  @value
25
45
  end
26
46
  end
27
47
 
28
- class StyleAccessor
48
+ # Read-side accessor. One subclass is generated per component class with a
49
+ # real method per declared style name (see accessor_for), so lookups never
50
+ # go through method_missing at render time and a typo raises loudly.
51
+ # BasicObject keeps Object/Kernel names (display, hash, ...) usable as
52
+ # style names on both mruby and CRuby.
53
+ class StyleAccessor < BasicObject
54
+ def self.accessor_for(definitions)
55
+ klass = ::Class.new(self) #: singleton(StyleAccessor)
56
+ definitions.each_key do |name|
57
+ klass.__send__(:define_method, name) do |variant = nil|
58
+ self[name, variant] # steep:ignore
59
+ end
60
+ end
61
+ klass
62
+ end
63
+
29
64
  def initialize(definitions)
30
65
  @definitions = definitions
31
66
  end
32
67
 
33
68
  def [](name, variant = nil)
34
69
  style = @definitions[name]
35
- return StyleValue.new("") unless style
70
+ unless style
71
+ ::Kernel.raise ::ArgumentError,
72
+ "unknown style :#{name} (declared: #{@definitions.keys.map { |k| ":#{k}" }.join(', ')})"
73
+ end
36
74
 
37
75
  if variant.nil?
38
76
  # No arguments: return base or value
39
77
  StyleValue.new(style[:base] || style[:value] || "")
40
78
  elsif variant == true || variant == false
41
79
  # Boolean argument: base + active (if true)
42
- # JS::Object#== now supports direct comparison with Ruby true/false
43
80
  base = style[:base] || ""
44
81
  active_class = (variant == true) ? (style[:active] || "") : ""
45
82
  StyleValue.new("#{base} #{active_class}".strip)
46
- elsif variant.is_a?(Symbol)
83
+ elsif ::Symbol === variant
47
84
  # Symbol argument: base + variants[symbol]
48
85
  base = style[:base] || ""
49
86
  variant_class = style[:variants] ? (style[:variants][variant] || "") : ""
@@ -53,27 +90,100 @@ module Funicular
53
90
  StyleValue.new(style[:base] || style[:value] || "")
54
91
  end
55
92
  end
93
+
94
+ def method_missing(name, *_args)
95
+ ::Kernel.raise ::NoMethodError,
96
+ "unknown style '#{name}' (declared: #{@definitions.keys.map { |k| ":#{k}" }.join(', ')})"
97
+ end
98
+
99
+ def respond_to_missing?(name, _include_private = false)
100
+ @definitions.key?(name)
101
+ end
56
102
  end
57
103
 
58
- class StyleBuilder
104
+ # Definition-side builder. The class-level `styles do ... end` block is
105
+ # instance_exec'd on an instance, so barewords define styles:
106
+ #
107
+ # styles do
108
+ # shell "app-shell"
109
+ # button base: "btn", variants: { primary: "btn--primary" }
110
+ # end
111
+ #
112
+ # BasicObject keeps the bareword namespace clean on both VMs; the block
113
+ # also receives the builder as an argument for the explicit form
114
+ # `styles { |css| css.define(:name, ...) }`, needed when a value is
115
+ # computed (a bareword helper call inside the block would be captured as
116
+ # a style definition, and its nil return raises via validation below).
117
+ class StyleBuilder < BasicObject
118
+ # Style names that would clobber the builder/accessor internals.
119
+ RESERVED_NAMES = %i[
120
+ initialize method_missing respond_to_missing? define to_definitions
121
+ validate_options
122
+ == != ! [] equal? __send__ __id__ instance_eval instance_exec
123
+ ]
124
+
59
125
  def initialize
60
- @definitions = {}
126
+ @definitions = {} #: Hash[Symbol, untyped]
61
127
  end
62
128
 
63
129
  def define(name, value = nil, **options)
64
- if value.is_a?(String)
130
+ if RESERVED_NAMES.include?(name)
131
+ ::Kernel.raise ::ArgumentError, "style name :#{name} is reserved"
132
+ end
133
+ if @definitions.key?(name)
134
+ ::Kernel.raise ::ArgumentError, "style :#{name} is already defined"
135
+ end
136
+
137
+ if ::String === value
65
138
  @definitions[name] = { value: value }
66
- elsif value.is_a?(Hash)
67
- @definitions[name] = value
68
- elsif !options.empty?
69
- @definitions[name] = options
139
+ elsif ::Hash === value
140
+ @definitions[name] = validate_options(name, value)
141
+ elsif (::NilClass === value) && !options.empty?
142
+ @definitions[name] = validate_options(name, options)
70
143
  else
71
- raise ArgumentError, "Invalid style definition for #{name}"
144
+ ::Kernel.raise ::ArgumentError,
145
+ "invalid style definition for :#{name}; expected a String, a " \
146
+ "Hash, or keyword options. Note: helper methods cannot be called " \
147
+ "bareword inside a styles block; use " \
148
+ "`styles { |css| css.define(...) }` for computed values."
72
149
  end
150
+ self
151
+ end
152
+
153
+ def validate_options(name, options)
154
+ options.each do |key, val|
155
+ case key
156
+ when :value, :base, :active
157
+ unless ::String === val
158
+ ::Kernel.raise ::ArgumentError,
159
+ "style :#{name} option #{key}: must be a String. Note: helper " \
160
+ "methods cannot be called bareword inside a styles block; use " \
161
+ "`styles { |css| css.define(...) }` for computed values."
162
+ end
163
+ when :variants
164
+ unless (::Hash === val) && val.all? { |_k, v| ::String === v }
165
+ ::Kernel.raise ::ArgumentError,
166
+ "style :#{name} option variants: must be a Hash of Symbol => String"
167
+ end
168
+ else
169
+ ::Kernel.raise ::ArgumentError,
170
+ "style :#{name} has unknown option #{key.inspect} " \
171
+ "(allowed: value, base, active, variants)"
172
+ end
173
+ end
174
+ options
73
175
  end
74
176
 
75
177
  def to_definitions
76
178
  @definitions
77
179
  end
180
+
181
+ def method_missing(name, value = nil, **options)
182
+ define(name, value, **options)
183
+ end
184
+
185
+ def respond_to_missing?(_name, _include_private = false)
186
+ true
187
+ end
78
188
  end
79
189
  end
@@ -1,40 +1,11 @@
1
1
  module Funicular
2
2
  class ViewContext
3
- HTML_TAGS = %w[
4
- div span p a data
5
- h1 h2 h3 h4 h5 h6
6
- ul ol li
7
- table thead tbody tr th td
8
- form input textarea button select option label
9
- header footer nav section article aside
10
- img video audio canvas
11
- br hr
12
- ]
3
+ HTML_TAGS = Funicular::Tags::HTML_TAGS
13
4
 
14
5
  def initialize(component)
15
6
  @component = component
16
7
  end
17
8
 
18
- def state
19
- @component.state
20
- end
21
-
22
- def props
23
- @component.props
24
- end
25
-
26
- def resources
27
- @component.resources
28
- end
29
-
30
- def styles
31
- @component.styles
32
- end
33
-
34
- def routes
35
- @component.runtime.routes
36
- end
37
-
38
9
  def tag(name, props = {}, &block)
39
10
  build_element(name, props, &block)
40
11
  end
@@ -66,7 +37,7 @@ module Funicular
66
37
  end
67
38
 
68
39
  def suspense(name, fallback:, error: nil, &block)
69
- @component.render_suspense(self, name, fallback: fallback, error: error, &block)
40
+ @component.render_suspense(name, fallback: fallback, error: error, &block)
70
41
  end
71
42
 
72
43
  def link_to(path, **options, &block)
@@ -81,7 +52,7 @@ module Funicular
81
52
  children = [] #: Array[Funicular::VDOM::child_t]
82
53
  previous = @component.current_children
83
54
  @component.current_children = children
84
- result = block.call(self)
55
+ result = block.call
85
56
  @component.current_children = previous
86
57
 
87
58
  if result && !result.equal?(children) && children.empty?