homura-runtime 0.2.16 → 0.2.18

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 411bf1884ba07170427e2f1a905b2848c81e8de9d53caea247006c7d41377f3e
4
- data.tar.gz: 83b2fc17572d59b4c93625553d2e46477e9df01161feaa76be41a8cb8e7acf5d
3
+ metadata.gz: 1bd6f3a366405aeb0ba2673c4827c7d34d8bef757d9194c64e71ca881bfe821f
4
+ data.tar.gz: 1ccc18ce83b42969bac914ecda37c21cdbfe79fd86971a1de1df1b25c3014302
5
5
  SHA512:
6
- metadata.gz: 1d8fb86ad2a4aee75cc5c1864b954d04ac8bbda9fd4c10a1151076eeb0996db4eca85f59fcd25ae6721e060f409097a8a83856c30b57bc063e9145156973380e
7
- data.tar.gz: 0a451bc7da52d28fd1d253bfe7b6885caa463b2f5790d53bbd2de0bc49f9ea7efe282c690a8131a0cc4fafa4dd720bc7f3571744280631acf5e423800343f3d5
6
+ metadata.gz: 777ce1497af06f6a5168bef5e195576fcdbd216a7da25b5d0eda657fea4757f074fb48f873f28eb3f6c5854c5ce98bf1f01dfa870b31a067eab93ef7dd8b9dce
7
+ data.tar.gz: 6a9e88cbfea6b27e46dc35c1bdb24bc02cdba8899839066d9d547057e18d9d67a319fdabed88a4ad36f852af49fd97bf41ef3aad964b720333bdd29c5c1438f6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.17 (2026-04-27)
4
+
5
+ - Rewrite class-variable references (`@@foo`) inside precompiled ERB
6
+ templates into explicit `class_variable_get` / `class_variable_set`
7
+ calls on the instance's class. Opal evaluates compiled template
8
+ bodies via `instance_exec` on a Sinatra instance whose `$$cvars`
9
+ slot is undefined at that runtime path, so the previous build emit
10
+ blew up with `TypeError: Cannot read properties of undefined (reading
11
+ '$$cvars')` whenever a template touched `<%= @@todos %>` or
12
+ `<% @@todos.each ... %>` directly. Templates can now use the
13
+ natural Sinatra style (`@@cvar` reads, `@@cvar = expr` and compound
14
+ `@@cvar op= expr` assignments) without route-level `@todos = @@todos`
15
+ shims. Fixes #28.
16
+
3
17
  ## 0.2.11 (2026-04-25)
4
18
 
5
19
  - Normalize bare JS `undefined` / `null` values to Ruby `nil` while converting
data/exe/compile-erb CHANGED
@@ -54,7 +54,94 @@ module HomuraERB
54
54
 
55
55
  def normalize_fragment(fragment)
56
56
  return '__homura_template_yield__' if supported_yield_fragment?(fragment)
57
- fragment
57
+ rewrite_class_variables(fragment)
58
+ end
59
+
60
+ # Opal compiles class-variable reads/writes (`@@foo`) into accessors
61
+ # bound to the lexically enclosing class, but precompiled ERB bodies
62
+ # are evaluated through `instance_exec` on a Sinatra instance whose
63
+ # `$$cvars` slot is undefined at that runtime path. The result is a
64
+ # `TypeError: Cannot read properties of undefined (reading '$$cvars')`
65
+ # the moment a template touches `<%= @@todos %>` directly — exactly
66
+ # the natural Sinatra style we want users to be able to keep writing.
67
+ #
68
+ # Rewrite both reads (`@@foo`) and writes (`@@foo = expr`) into
69
+ # explicit `class_variable_get` / `class_variable_set` calls on the
70
+ # instance's class so the same source compiles cleanly under Opal
71
+ # without requiring `@foo = @@foo` shims at the route layer.
72
+ #
73
+ # The rewrite is a deliberately small regex pass: ERB fragments are
74
+ # short, and class-variable tokens inside Ruby string literals or
75
+ # comments are vanishingly rare in real templates. Anything more
76
+ # ambitious would need a Ruby parser, which is overkill for the
77
+ # `@@ivar` shape we actually have to handle here.
78
+ CVAR_OP_ASSIGN_RE = /(?<![@\w])@@([A-Za-z_]\w*)\s*(\|\||&&|\+|-|\*|\/|%|\*\*|<<|>>|\||&|\^)=\s*([^;\n]+)/.freeze
79
+ CVAR_ASSIGN_RE = /(?<![@\w])@@([A-Za-z_]\w*)\s*=(?!=)\s*([^;\n]+)/.freeze
80
+ # Read pattern excludes `@@name` that appears on the left-hand side
81
+ # of an assignment (`@@name =`, `@@name +=`, `@@name ||=`, etc.) so
82
+ # the assignment passes still see the raw token to rewrite. The
83
+ # `=(?!=)` guard keeps `==` (comparison) treated as a read.
84
+ CVAR_READ_RE = %r{
85
+ (?<![@\w])@@([A-Za-z_]\w*)\b
86
+ (?!
87
+ \s*
88
+ (?:\|\||&&|\+|-|\*|/|%|\*\*|<<|>>|\||&|\^)?
89
+ =(?!=)
90
+ )
91
+ }x.freeze
92
+
93
+ CVAR_PLACEHOLDER_PREFIX = "\x01HOMURA_CVAR\x01".freeze
94
+ CVAR_PLACEHOLDER_SUFFIX = "\x01".freeze
95
+
96
+ def rewrite_class_variables(fragment)
97
+ return fragment unless fragment.include?('@@')
98
+
99
+ placeholders = []
100
+ record = lambda do |replacement|
101
+ placeholders << replacement
102
+ "#{CVAR_PLACEHOLDER_PREFIX}#{placeholders.length - 1}#{CVAR_PLACEHOLDER_SUFFIX}"
103
+ end
104
+
105
+ # Reads first: rewrite every `@@name` that is *not* the target of
106
+ # an assignment, including occurrences on the right-hand side of
107
+ # an assignment. Each match collapses into a sentinel so the
108
+ # later assignment passes see the original `@@name = ...` shape
109
+ # (without their RHS reads being clobbered) and so the final
110
+ # output never re-enters this same rewrite loop.
111
+ work = fragment.gsub(CVAR_READ_RE) do
112
+ name = Regexp.last_match(1)
113
+ record.call("self.class.class_variable_get(:@@#{name})")
114
+ end
115
+
116
+ work = work.gsub(CVAR_OP_ASSIGN_RE) do
117
+ name = Regexp.last_match(1)
118
+ op = Regexp.last_match(2)
119
+ value = Regexp.last_match(3)
120
+ replacement =
121
+ case op
122
+ when '||'
123
+ "self.class.class_variable_set(:@@#{name}, (self.class.class_variable_defined?(:@@#{name}) ? self.class.class_variable_get(:@@#{name}) : nil) || (#{value}))"
124
+ when '&&'
125
+ "self.class.class_variable_set(:@@#{name}, (self.class.class_variable_defined?(:@@#{name}) ? self.class.class_variable_get(:@@#{name}) : nil) && (#{value}))"
126
+ else
127
+ "self.class.class_variable_set(:@@#{name}, self.class.class_variable_get(:@@#{name}) #{op} (#{value}))"
128
+ end
129
+ record.call(replacement)
130
+ end
131
+
132
+ work = work.gsub(CVAR_ASSIGN_RE) do
133
+ name = Regexp.last_match(1)
134
+ value = Regexp.last_match(2)
135
+ record.call("self.class.class_variable_set(:@@#{name}, (#{value}))")
136
+ end
137
+
138
+ placeholder_re = /#{Regexp.escape(CVAR_PLACEHOLDER_PREFIX)}(\d+)#{Regexp.escape(CVAR_PLACEHOLDER_SUFFIX)}/
139
+ loop do
140
+ replaced = work.gsub(placeholder_re) { placeholders[Regexp.last_match(1).to_i] }
141
+ break if replaced == work
142
+ work = replaced
143
+ end
144
+ work
58
145
  end
59
146
 
60
147
  def validate_code_fragment!(fragment)
@@ -200,11 +287,21 @@ def emit_header(io, namespace)
200
287
  body = @templates[name.to_sym]
201
288
  raise "#{namespace}: no template registered for \#{name.inspect}" unless body
202
289
  previous_block = instance.instance_variable_get(:@__homura_template_block__)
290
+ previous_locals = instance.instance_variable_get(:@__homura_template_locals__)
203
291
  begin
204
292
  instance.instance_variable_set(:@__homura_template_block__, block)
293
+ # Stack locals so nested `erb :_partial, locals: {...}` calls
294
+ # restore the outer scope on the way out — Sinatra's standard
295
+ # `locals[:t]` -> bare `t` lookup is implemented via
296
+ # method_missing on the Sinatra instance (see Sinatra::Base
297
+ # patch installed by emit_sinatra_patch).
298
+ stack = previous_locals.is_a?(::Array) ? previous_locals.dup : []
299
+ stack.push(locals || {})
300
+ instance.instance_variable_set(:@__homura_template_locals__, stack)
205
301
  instance.instance_exec(locals, &body)
206
302
  ensure
207
303
  instance.instance_variable_set(:@__homura_template_block__, previous_block)
304
+ instance.instance_variable_set(:@__homura_template_locals__, previous_locals)
208
305
  end
209
306
  end
210
307
 
@@ -254,6 +351,61 @@ def emit_sinatra_patch(io, namespace)
254
351
  block.call
255
352
  end
256
353
 
354
+ # Resolve a Sinatra-style local variable reference.
355
+ # `erb :_partial, locals: { t: row }` causes the precompiled body
356
+ # to reference a bare `t`. Stock Sinatra uses Ruby's `binding`
357
+ # eval to install locals into the template's scope; we don't have
358
+ # `binding` under Opal/Workers, so we hand-roll the lookup
359
+ # through method_missing against the locals stack maintained by
360
+ # the renderer.
361
+ def __homura_template_local__(name)
362
+ stack = @__homura_template_locals__
363
+ return nil unless stack.is_a?(::Array)
364
+ key = name.to_sym
365
+ stack.reverse_each do |frame|
366
+ next unless frame.is_a?(::Hash)
367
+ return frame[key] if frame.key?(key)
368
+ sk = name.to_s
369
+ return frame[sk] if frame.key?(sk)
370
+ end
371
+ nil
372
+ end
373
+
374
+ def __homura_template_local_defined?(name)
375
+ stack = @__homura_template_locals__
376
+ return false unless stack.is_a?(::Array)
377
+ key = name.to_sym
378
+ sk = name.to_s
379
+ stack.reverse_each do |frame|
380
+ next unless frame.is_a?(::Hash)
381
+ return true if frame.key?(key) || frame.key?(sk)
382
+ end
383
+ false
384
+ end
385
+ end
386
+ end
387
+
388
+ # Method-missing fallback that surfaces template locals as bare names.
389
+ # Installed on Sinatra::Base so `<%= t['title'] %>` inside a
390
+ # precompiled partial Just Works without the user having to write
391
+ # `<%= locals[:t]['title'] %>`.
392
+ class ::Sinatra::Base
393
+ def method_missing(name, *args, &block)
394
+ if args.empty? && block.nil? && __homura_template_local_defined?(name)
395
+ return __homura_template_local__(name)
396
+ end
397
+ super
398
+ end
399
+
400
+ def respond_to_missing?(name, include_private = false)
401
+ return true if __homura_template_local_defined?(name)
402
+ super
403
+ end
404
+ end
405
+
406
+ module ::Sinatra
407
+ module Templates
408
+
257
409
  def __homura_default_template_block_for__(template)
258
410
  case template.to_sym
259
411
  when :layout
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CloudflareWorkers
4
- VERSION = '0.2.16'
4
+ VERSION = '0.2.18'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: homura-runtime
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.16
4
+ version: 0.2.18
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kazuhiro Homma