salvia 0.2.0 → 0.2.2

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
 
@@ -120,7 +74,9 @@ module Salvia
120
74
  # @example ハイドレーションを無効化 (静的 HTML のみ)
121
75
  # <%= island "StaticCard", title: "Hello", hydrate: false %>
122
76
  #
77
+ # @deprecated Use Salvia::SSR.render in controller instead.
123
78
  def island(name, props = {}, options = {})
79
+ warn "[DEPRECATION] `island` helper is deprecated. Please use `Salvia::SSR.render` in your controller (API Mode) instead."
124
80
  tag_name = options.delete(:tag) || :div
125
81
 
126
82
  # デフォルトの hydrate 値を決定
@@ -190,18 +146,11 @@ module Salvia
190
146
  #
191
147
  # @param name [String] ページコンポーネント名 (例: "pages/Home")
192
148
  # @param props [Hash] プロパティ
149
+ # @param options [Hash] オプション
150
+ # @option options [Boolean] :doctype <!DOCTYPE html> を付与するか (デフォルト: true)
193
151
  # @return [String] 完全な HTML 文字列
194
- def ssr(name, props = {})
195
- # SSR HTML を生成
196
- html = Salvia::SSR.render(name, props)
197
-
198
- # <head> がある場合、Import Map を自動注入
199
- if html.include?("</head>")
200
- import_map_html = salvia_import_map
201
- html = html.sub("</head>", "#{import_map_html}</head>")
202
- end
203
-
204
- result = "<!DOCTYPE html>\n" + html
152
+ def ssr(name, props = {}, options = {})
153
+ result = Salvia::SSR.render_page(name, props, options)
205
154
  result.respond_to?(:html_safe) ? result.html_safe : result
206
155
  end
207
156
 
@@ -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,8 +40,9 @@ module Salvia
31
40
  return serve_islands_js
32
41
  end
33
42
 
34
- # Remove .js extension to find source
35
- base_name = path_info.sub(/\.js$/, "")
43
+ # Remove extension to find source (supports .js, .tsx, .ts, .jsx)
44
+ # Also handle requests without extension (from import map resolution)
45
+ base_name = path_info.sub(/\.(js|tsx|ts|jsx)$/, "")
36
46
 
37
47
  source_path = resolve_source_path(base_name)
38
48
 
@@ -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() { }