salvia 0.2.0 → 0.2.1

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.
@@ -5,32 +5,132 @@ require "json"
5
5
  module Salvia
6
6
  module Core
7
7
  class ImportMap
8
- def self.load
9
- new.load
10
- end
8
+ def self.generate(additional_map = {})
9
+ new.generate(additional_map)
10
+ end
11
+
12
+ def generate(additional_map = {})
13
+ map = { "imports" => default_imports }
14
+
15
+ # Merge imports from deno.json
16
+ deno_imports = load_deno_imports
17
+ map["imports"].merge!(deno_imports) if deno_imports
11
18
 
12
- def load
13
- path = find_deno_json
14
- return {} unless path
19
+ # Merge islands mapping
20
+ map["imports"].merge!(islands_mapping)
15
21
 
16
- begin
17
- content = File.read(path)
18
- json = JSON.parse(content)
19
- json["imports"] || {}
20
- rescue JSON::ParserError
21
- {}
22
+ # Merge additional map
23
+ if additional_map["imports"]
24
+ map["imports"].merge!(additional_map["imports"])
25
+ end
26
+
27
+ # Merge other keys
28
+ additional_map.each do |k, v|
29
+ next if k == "imports"
30
+ map[k] = v
31
+ end
32
+
33
+ map
22
34
  end
23
- end
24
35
 
25
- def keys
26
- load.keys
27
- end
36
+ def load
37
+ path = find_deno_json
38
+ return {} unless path
28
39
 
29
- private
40
+ begin
41
+ content = File.read(path)
42
+ json = JSON.parse(content)
43
+ json["imports"] || {}
44
+ rescue JSON::ParserError
45
+ {}
46
+ end
47
+ end
30
48
 
31
- def find_deno_json
32
- Salvia.config.deno_config_path
33
- end
49
+ def keys
50
+ load.keys
51
+ end
52
+
53
+ private
54
+
55
+ def default_imports
56
+ {
57
+ "preact" => "https://esm.sh/preact@10.19.6",
58
+ "preact/hooks" => "https://esm.sh/preact@10.19.6/hooks",
59
+ "preact/jsx-runtime" => "https://esm.sh/preact@10.19.6/jsx-runtime",
60
+ "@hotwired/turbo" => "https://esm.sh/@hotwired/turbo@8.0.0"
61
+ }
62
+ end
63
+
64
+ def load_deno_imports
65
+ imports = load
66
+ return nil if imports.empty?
67
+
68
+ # Convert npm: scheme to https://esm.sh/
69
+ imports.transform_values do |v|
70
+ if v.is_a?(String) && v.start_with?("npm:")
71
+ package = v.sub("npm:", "")
72
+ "https://esm.sh/#{package}"
73
+ else
74
+ v
75
+ end
76
+ end
77
+ end
78
+
79
+ def islands_mapping
80
+ mapping = {}
81
+ islands_path = Salvia.development? ? "/salvia/assets/islands/" : "/assets/islands/"
82
+
83
+ # Base mapping for directory
84
+ mapping["@/islands/"] = islands_path
85
+
86
+ if Salvia.development?
87
+ # Development: Scan islands directory and map each component
88
+ # This ensures we can import "Counter" and get "Counter.tsx" (or whatever DevServer serves)
89
+ # Note: DevServer should handle extension resolution or we map to specific files here.
90
+ # Assuming DevServer handles extensionless requests or we map to .js if compiled on the fly.
91
+ # For now, we map to the file path relative to islands_path.
92
+ # If we use Sidecar/DevServer, it likely serves compiled JS.
93
+
94
+ # Scan app/islands
95
+ islands_dir = Salvia.config.islands_dir
96
+ if File.directory?(islands_dir)
97
+ Dir.glob("#{islands_dir}/**/*.{tsx,jsx,js}").each do |file|
98
+ next if File.basename(file).start_with?("_")
99
+
100
+ name = File.basename(file, ".*")
101
+ # Map "Counter" -> "/salvia/assets/islands/Counter.tsx" (or let DevServer handle it)
102
+ # If we map to extensionless, browser requests extensionless.
103
+ # Let's assume DevServer handles it.
104
+ # But to be safe and consistent with prod, we might want to map to a specific URL.
105
+
106
+ # For simplicity in dev, we can just rely on @/islands/ prefix if imports use extensions.
107
+ # But if imports are "Counter", we need a map.
108
+ mapping["@/islands/#{name}"] = File.join(islands_path, File.basename(file))
109
+ end
110
+ end
111
+ else
112
+ # Production: Use manifest
113
+ manifest_path = File.join(Salvia.root, "salvia/server/manifest.json")
114
+ if File.exist?(manifest_path)
115
+ begin
116
+ manifest = JSON.parse(File.read(manifest_path))
117
+ manifest.each do |name, info|
118
+ if info["file"]
119
+ mapping["@/islands/#{name}"] = File.join(islands_path, info["file"])
120
+ end
121
+ end
122
+ rescue JSON::ParserError
123
+ # Ignore error
124
+ end
125
+ end
126
+ end
127
+
128
+ mapping
129
+ end
130
+
131
+ def find_deno_json
132
+ Salvia.config.deno_config_path
133
+ end
34
134
  end
35
135
  end
36
136
  end
@@ -40,57 +40,11 @@ module Salvia
40
40
 
41
41
  # Import Map タグを生成する
42
42
  def salvia_import_map(additional_map = {})
43
- default_map = {
44
- "imports" => {
45
- "preact" => "https://esm.sh/preact@10.19.6",
46
- "preact/hooks" => "https://esm.sh/preact@10.19.6/hooks",
47
- "preact/jsx-runtime" => "https://esm.sh/preact@10.19.6/jsx-runtime",
48
- "@hotwired/turbo" => "https://esm.sh/@hotwired/turbo@8.0.0"
49
- }
50
- }
51
-
52
- # Islands path mapping
53
- islands_path = if defined?(Salvia.env) && Salvia.env == "development"
54
- "/salvia/assets/islands/"
55
- else
56
- "/assets/islands/"
57
- end
58
- default_map["imports"]["@/islands/"] = islands_path
59
-
60
- # deno.json から imports を読み込む
61
- begin
62
- deno_json_path = File.join(Salvia.root, "salvia/deno.json")
63
- if File.exist?(deno_json_path)
64
- deno_config = JSON.parse(File.read(deno_json_path))
65
- if deno_config["imports"]
66
- # npm: スキームを https://esm.sh/ に変換してブラウザで使えるようにする
67
- imports = deno_config["imports"].transform_values do |v|
68
- if v.is_a?(String) && v.start_with?("npm:")
69
- package = v.sub("npm:", "")
70
- "https://esm.sh/#{package}"
71
- else
72
- v
73
- end
74
- end
75
- default_map["imports"].merge!(imports)
76
- end
77
- end
78
- rescue => e
79
- # 読み込みエラー時は無視
80
- end
81
-
82
- if additional_map.key?("imports")
83
- default_map["imports"].merge!(additional_map["imports"])
84
- end
85
-
86
- additional_map.each do |k, v|
87
- next if k == "imports"
88
- default_map[k] = v
89
- end
43
+ map = Salvia::Core::ImportMap.generate(additional_map)
90
44
 
91
45
  html = <<~HTML
92
46
  <script type="importmap">
93
- #{default_map.to_json}
47
+ #{map.to_json}
94
48
  </script>
95
49
  HTML
96
50
 
@@ -190,8 +144,10 @@ module Salvia
190
144
  #
191
145
  # @param name [String] ページコンポーネント名 (例: "pages/Home")
192
146
  # @param props [Hash] プロパティ
147
+ # @param options [Hash] オプション
148
+ # @option options [Boolean] :doctype <!DOCTYPE html> を付与するか (デフォルト: true)
193
149
  # @return [String] 完全な HTML 文字列
194
- def ssr(name, props = {})
150
+ def ssr(name, props = {}, options = {})
195
151
  # SSR で HTML を生成
196
152
  html = Salvia::SSR.render(name, props)
197
153
 
@@ -201,7 +157,11 @@ module Salvia
201
157
  html = html.sub("</head>", "#{import_map_html}</head>")
202
158
  end
203
159
 
204
- result = "<!DOCTYPE html>\n" + html
160
+ result = html
161
+ if options.fetch(:doctype, true)
162
+ result = "<!DOCTYPE html>\n" + result
163
+ end
164
+
205
165
  result.respond_to?(:html_safe) ? result.html_safe : result
206
166
  end
207
167
 
@@ -7,14 +7,23 @@ module Salvia
7
7
 
8
8
  def call(env)
9
9
  # Only active in development
10
- unless ENV["RACK_ENV"] == "development" || ENV["RAILS_ENV"] == "development"
11
- return @app.call(env)
12
- end
10
+ # Prefer Rails.env if available, otherwise check ENV
11
+ is_dev = if defined?(Rails) && Rails.respond_to?(:env)
12
+ Rails.env.development?
13
+ else
14
+ ENV["RACK_ENV"] == "development" || ENV["RAILS_ENV"] == "development"
15
+ end
16
+
17
+ return @app.call(env) unless is_dev
13
18
 
14
19
  request = Rack::Request.new(env)
15
20
 
16
21
  if request.path.start_with?("/salvia/assets/")
17
22
  handle_asset_request(request)
23
+ elsif request.path == "/assets/javascripts/islands.js"
24
+ # Handle islands.js specifically for development convenience
25
+ # This matches the path used in Home.tsx template and ensures it works without build
26
+ serve_islands_js
18
27
  else
19
28
  @app.call(env)
20
29
  end
@@ -31,7 +40,8 @@ module Salvia
31
40
  return serve_islands_js
32
41
  end
33
42
 
34
- # Remove .js extension to find source
43
+ # Remove .js extension to find source (if present)
44
+ # Also handle requests without extension (from import map resolution)
35
45
  base_name = path_info.sub(/\.js$/, "")
36
46
 
37
47
  source_path = resolve_source_path(base_name)
@@ -44,8 +54,6 @@ module Salvia
44
54
  # Bundle for browser (ESM)
45
55
  # We externalize dependencies that should be handled by Import Map
46
56
  externals = Salvia::Core::ImportMap.new.keys
47
- # Always externalize framework aliases just in case
48
- externals += ["framework", "framework/hooks", "framework/jsx-runtime"]
49
57
 
50
58
  js_code = Salvia::Compiler.bundle(
51
59
  source_path,
@@ -64,13 +72,19 @@ module Salvia
64
72
  end
65
73
 
66
74
  def serve_islands_js
67
- # Check user's islands.js
75
+ # 1. Check public assets (Build output or user override)
76
+ public_path = File.join(Salvia.root, "public/assets/javascripts/islands.js")
77
+ if File.exist?(public_path)
78
+ return [200, { "content-type" => "application/javascript" }, [File.read(public_path)]]
79
+ end
80
+
81
+ # 2. Check user's source (salvia/assets/javascripts/islands.js) - Legacy/Custom path
68
82
  user_path = File.join(Salvia.root, "salvia/assets/javascripts/islands.js")
69
83
  if File.exist?(user_path)
70
84
  return [200, { "content-type" => "application/javascript" }, [File.read(user_path)]]
71
85
  end
72
86
 
73
- # Fallback to internal islands.js
87
+ # 3. Fallback to internal islands.js
74
88
  internal_path = File.expand_path("../../../assets/javascripts/islands.js", __dir__)
75
89
  if File.exist?(internal_path)
76
90
  return [200, { "content-type" => "application/javascript" }, [File.read(internal_path)]]
@@ -6,7 +6,7 @@ require 'timeout'
6
6
  module Salvia
7
7
  module Server
8
8
  class Sidecar
9
- SCRIPT_PATH = File.join(__dir__, "sidecar.ts")
9
+ SCRIPT_PATH = File.expand_path("../../../assets/scripts/sidecar.ts", __dir__)
10
10
 
11
11
  def self.instance
12
12
  @instance ||= new
@@ -15,45 +15,70 @@ module Salvia
15
15
  def initialize
16
16
  @pid = nil
17
17
  @port = nil
18
+ @mutex = Mutex.new
18
19
  at_exit { stop }
19
20
  end
20
21
 
21
22
  def start
22
- return if running?
23
+ @mutex.synchronize do
24
+ return if running?
23
25
 
24
- cmd = ["deno", "run", "--allow-all", SCRIPT_PATH]
25
-
26
- puts "🚀 Starting Salvia Sidecar..."
27
- # Spawn process and capture stdout to find the port
28
- # We use IO.popen to read the output stream
29
- @io = IO.popen(cmd)
30
- @pid = @io.pid
31
-
32
- # Wait for "Listening on http://localhost:PORT/"
33
- wait_for_port
34
-
35
- # Detach so it runs in background, but we keep the IO open to read logs if needed
36
- # Actually, for IO.popen, we shouldn't detach if we want to read from it.
37
- # But we need to read in a non-blocking way or in a separate thread after finding the port.
38
-
39
- Thread.new do
40
- begin
41
- while line = @io.gets
42
- # Forward Deno logs to stdout/logger
43
- puts "[Deno] #{line}"
26
+ cmd = ["deno", "run", "--allow-net", "--allow-read", "--allow-env", "--allow-run", SCRIPT_PATH]
27
+
28
+ puts "🚀 Starting Salvia Sidecar..."
29
+ # Spawn process and capture stdout to find the port
30
+ # We use IO.popen to read the output stream
31
+ @io = IO.popen(cmd, err: [:child, :out])
32
+ @pid = @io.pid
33
+
34
+ # Wait for JSON handshake
35
+ wait_for_handshake
36
+
37
+ # Detach so it runs in background, but we keep the IO open to read logs if needed
38
+ # Actually, for IO.popen, we shouldn't detach if we want to read from it.
39
+ # But we need to read in a non-blocking way or in a separate thread after finding the port.
40
+
41
+ Thread.new do
42
+ begin
43
+ while line = @io.gets
44
+ # Forward Deno logs to stdout/logger
45
+ puts "[Deno] #{line}"
46
+ end
47
+ rescue IOError
48
+ # Stream closed
44
49
  end
45
- rescue IOError
46
- # Stream closed
47
50
  end
48
51
  end
49
52
  end
50
53
 
51
54
  def stop
52
55
  return unless @pid
53
- Process.kill("TERM", @pid)
54
- @pid = nil
55
- @port = nil
56
- @io.close if @io && !@io.closed?
56
+
57
+ begin
58
+ Process.kill("TERM", @pid)
59
+
60
+ # Wait up to 5 seconds for graceful shutdown
61
+ 50.times do
62
+ pid = Process.waitpid(@pid, Process::WNOHANG)
63
+ if pid
64
+ @pid = nil
65
+ break
66
+ end
67
+ sleep 0.1
68
+ end
69
+
70
+ # Force kill if still running
71
+ if @pid
72
+ Process.kill("KILL", @pid)
73
+ Process.waitpid(@pid, Process::WNOHANG) rescue nil
74
+ end
75
+ rescue Errno::ESRCH, Errno::ECHILD
76
+ # Process already dead
77
+ ensure
78
+ @pid = nil
79
+ @port = nil
80
+ @io.close if @io && !@io.closed?
81
+ end
57
82
  end
58
83
 
59
84
  def running?
@@ -97,16 +122,28 @@ module Salvia
97
122
 
98
123
  private
99
124
 
100
- def wait_for_port
101
- Timeout.timeout(10) do
125
+ def wait_for_handshake
126
+ Timeout.timeout(30) do
102
127
  while line = @io.gets
103
- puts "[Deno Init] #{line}"
104
- if match = line.match(/Listening on http:\/\/localhost:(\d+)\//)
105
- @port = match[1].to_i
106
- puts "✅ Salvia Sidecar connected on port #{@port}"
107
- return
128
+ # Try to parse JSON handshake
129
+ if line.strip.start_with?("{") && line.include?("port")
130
+ begin
131
+ data = JSON.parse(line)
132
+ if data["port"]
133
+ @port = data["port"]
134
+ puts "✅ Salvia Sidecar connected on port #{@port}"
135
+ return
136
+ end
137
+ rescue JSON::ParserError
138
+ # Not JSON, just log
139
+ puts "[Deno Init] #{line}"
140
+ end
141
+ else
142
+ puts "[Deno Init] #{line}"
108
143
  end
109
144
  end
145
+ # If we exit the loop, it means EOF (process died)
146
+ raise "Deno Sidecar crashed unexpectedly. Check logs."
110
147
  end
111
148
  rescue Timeout::Error
112
149
  stop
@@ -12,12 +12,20 @@ module Salvia
12
12
  globalThis.addEventListener = function() {};
13
13
  globalThis.removeEventListener = function() {};
14
14
  globalThis.document = {
15
- createElement: function() { return {}; },
15
+ createElement: function(tag) {
16
+ return {
17
+ tagName: tag ? tag.toUpperCase() : 'DIV',
18
+ setAttribute: function() {},
19
+ appendChild: function() {},
20
+ style: {}
21
+ };
22
+ },
16
23
  createTextNode: function() { return {}; },
17
24
  addEventListener: function() { },
18
25
  removeEventListener: function() { },
19
- head: {},
20
- body: {},
26
+ head: { appendChild: function() {} },
27
+ body: { appendChild: function() {} },
28
+ getElementById: function() { return null; },
21
29
  documentElement: {
22
30
  addEventListener: function() { },
23
31
  removeEventListener: function() { }
@@ -15,21 +15,13 @@ module Salvia
15
15
  @last_build_error = nil
16
16
  @development = options.fetch(:development, true)
17
17
 
18
- @vm = ::Quickjs::VM.new
19
-
20
- load_console_shim!
21
-
22
- if @development
23
- load_vendor_bundle!
24
- else
25
- load_ssr_bundle!
26
- end
18
+ # VM initialization is deferred to thread-local access
27
19
 
28
20
  mark_initialized!
29
21
  end
30
22
 
31
23
  def shutdown!
32
- @vm = nil
24
+ Thread.current[:salvia_quickjs_vm] = nil
33
25
  @js_logs = []
34
26
  @initialized = false
35
27
  Salvia::Compiler.shutdown if @development
@@ -49,7 +41,24 @@ module Salvia
49
41
  private
50
42
 
51
43
  def vm
52
- @vm
44
+ Thread.current[:salvia_quickjs_vm] ||= create_thread_local_vm
45
+ end
46
+
47
+ def reset_vm!
48
+ Thread.current[:salvia_quickjs_vm] = nil
49
+ end
50
+
51
+ def create_thread_local_vm
52
+ new_vm = ::Quickjs::VM.new
53
+ load_console_shim!(new_vm)
54
+
55
+ if @development
56
+ load_vendor_bundle!(new_vm)
57
+ else
58
+ load_ssr_bundle!(new_vm)
59
+ end
60
+
61
+ new_vm
53
62
  end
54
63
 
55
64
  def render_jit(component_name, props)
@@ -59,23 +68,57 @@ module Salvia
59
68
  raise Error, "Component not found: #{component_name}"
60
69
  end
61
70
 
62
- # Bundle component
63
- js_code = Salvia::Compiler.bundle(
64
- path,
65
- externals: ["preact", "preact/hooks", "preact-render-to-string"],
66
- format: "iife",
67
- global_name: "SalviaComponent"
68
- )
71
+ # Bundle component (with simple memory cache)
72
+ @bundle_cache ||= {}
73
+ mtime = File.mtime(path)
69
74
 
70
- # Async Type Check
71
- Thread.new do
75
+ if @bundle_cache[path].nil? || @bundle_cache[path][:mtime] != mtime
76
+ # Load externals from deno.json if possible, otherwise use defaults
77
+ externals = ["preact", "preact/hooks", "preact/jsx-runtime", "preact-render-to-string", "@preact/signals"]
78
+
72
79
  begin
73
- result = Salvia::Compiler.check(path)
74
- unless result["success"]
75
- log_warn("Type Check Failed for #{component_name}:\n#{result["message"]}")
80
+ deno_json_path = File.join(Salvia.root, "salvia/deno.json")
81
+ if File.exist?(deno_json_path)
82
+ deno_config = JSON.parse(File.read(deno_json_path))
83
+ if deno_config["imports"]
84
+ externals = (externals + deno_config["imports"].keys).uniq
85
+ end
76
86
  end
77
87
  rescue => e
78
- log_debug("Type Check Error: #{e.message}")
88
+ log_warn("Failed to load externals from deno.json: #{e.message}")
89
+ end
90
+
91
+ js_code = Salvia::Compiler.bundle(
92
+ path,
93
+ externals: externals,
94
+ format: "iife",
95
+ global_name: "SalviaComponent"
96
+ )
97
+ @bundle_cache[path] = { code: js_code, mtime: mtime }
98
+ end
99
+
100
+ js_code = @bundle_cache[path][:code]
101
+
102
+ # Async Type Check (Debounced)
103
+ @last_check_time ||= {}
104
+ now = Time.now.to_i
105
+
106
+ # Debounce: Check at most once every 5 seconds per file
107
+ if @last_check_time[path].nil? || (now - @last_check_time[path]) > 5
108
+ @last_check_time[path] = now
109
+
110
+ # Use a background thread but avoid spawning too many
111
+ # Ideally this should be a single worker queue, but for now simple detach is better than nothing
112
+ Thread.new do
113
+ begin
114
+ # Double check inside thread to handle race conditions slightly better
115
+ result = Salvia::Compiler.check(path)
116
+ unless result["success"]
117
+ log_warn("Type Check Failed for #{component_name}:\n#{result["message"]}")
118
+ end
119
+ rescue => e
120
+ log_debug("Type Check Error: #{e.message}")
121
+ end
79
122
  end
80
123
  end
81
124
 
@@ -148,7 +191,7 @@ module Salvia
148
191
  end
149
192
 
150
193
  def eval_js(code)
151
- result = @vm.eval_code(code)
194
+ result = vm.eval_code(code)
152
195
  process_console_output
153
196
  result
154
197
  rescue => e
@@ -156,21 +199,24 @@ module Salvia
156
199
  raise e
157
200
  end
158
201
 
159
- def load_console_shim!
202
+ def load_console_shim!(target_vm)
160
203
  shim = generate_console_shim
161
- @vm.eval_code(shim)
162
- @vm.eval_code(Salvia::SSR::DomMock.generate_shim)
204
+ target_vm.eval_code(shim)
205
+ target_vm.eval_code(Salvia::SSR::DomMock.generate_shim)
163
206
  rescue => e
164
207
  log_error("Failed to load console shim: #{e.message}")
165
208
  end
166
209
 
167
- def load_vendor_bundle!
210
+ def load_vendor_bundle!(target_vm)
168
211
  # Use internal vendor_setup.ts for Zero Config
169
212
  vendor_path = File.expand_path("../../../assets/scripts/vendor_setup.ts", __dir__)
170
213
 
171
214
  if File.exist?(vendor_path)
215
+ # Ensure module.exports shim exists before loading vendor bundle
216
+ target_vm.eval_code("if(typeof module === 'undefined') { globalThis.module = { exports: {} }; }")
217
+
172
218
  code = Salvia::Compiler.bundle(vendor_path, format: "iife")
173
- eval_js(code)
219
+ target_vm.eval_code(code)
174
220
  log_info("Loaded Vendor bundle (Internal)")
175
221
  else
176
222
  log_error("Internal vendor_setup.ts not found at #{vendor_path}")
@@ -179,15 +225,27 @@ module Salvia
179
225
  log_error("Failed to load vendor bundle: #{e.message}")
180
226
  end
181
227
 
182
- def load_ssr_bundle!
228
+ def load_ssr_bundle!(target_vm)
183
229
  bundle_path = options[:bundle_path] || default_bundle_path
184
230
 
185
231
  unless File.exist?(bundle_path)
186
232
  raise Error, "SSR bundle not found: #{bundle_path}"
187
233
  end
188
234
 
235
+ # Check if bundle has changed (simple reload strategy)
236
+ mtime = File.mtime(bundle_path)
237
+ if @last_bundle_mtime && @last_bundle_mtime != mtime
238
+ log_info("SSR bundle changed, reloading...")
239
+ # Note: In a real scenario, we might need to reset the VM completely
240
+ # But since this is called inside create_thread_local_vm, we are creating a new VM anyway.
241
+ # However, if we want to support hot reload in production without restarting threads,
242
+ # we would need a mechanism to invalidate all thread-local VMs.
243
+ # For now, we just update the mtime tracking.
244
+ end
245
+ @last_bundle_mtime = mtime
246
+
189
247
  bundle_content = File.read(bundle_path)
190
- @vm.eval_code(bundle_content)
248
+ target_vm.eval_code(bundle_content)
191
249
  log_info("Loaded SSR bundle: #{bundle_path}")
192
250
  end
193
251
 
@@ -196,7 +254,7 @@ module Salvia
196
254
  end
197
255
 
198
256
  def process_console_output
199
- logs_json = @vm.eval_code("globalThis.__salvia_flush_logs__()")
257
+ logs_json = vm.eval_code("globalThis.__salvia_flush_logs__()")
200
258
  return if logs_json.nil? || logs_json.empty?
201
259
 
202
260
  begin
@@ -270,7 +328,12 @@ module Salvia
270
328
  end
271
329
 
272
330
  def escape_html(str)
273
- str.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
331
+ str.to_s
332
+ .gsub("&", "&amp;")
333
+ .gsub("<", "&lt;")
334
+ .gsub(">", "&gt;")
335
+ .gsub('"', "&quot;")
336
+ .gsub("'", "&#39;")
274
337
  end
275
338
 
276
339
  def log_info(msg); defined?(Salvia.logger) ? Salvia.logger.info(msg) : puts("[SSR] #{msg}"); end