salvia 0.2.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.
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Salvia
6
+ module Helpers
7
+ module Island
8
+ # Island マニフェストをキャッシュ
9
+ @manifest = nil
10
+ @manifest_mtime = nil
11
+
12
+ class << self
13
+ # マニフェストを読み込む
14
+ def load_manifest
15
+ manifest_path = File.join(Dir.pwd, "salvia/server/manifest.json")
16
+ return {} unless File.exist?(manifest_path)
17
+
18
+ mtime = File.mtime(manifest_path)
19
+ if @manifest.nil? || @manifest_mtime != mtime
20
+ @manifest = JSON.parse(File.read(manifest_path))
21
+ @manifest_mtime = mtime
22
+ end
23
+ @manifest
24
+ rescue => e
25
+ {}
26
+ end
27
+
28
+ # Island が client only かどうか
29
+ def client_only?(name)
30
+ manifest = load_manifest
31
+ manifest.dig(name, "clientOnly") == true
32
+ end
33
+
34
+ # Island が server only かどうか
35
+ def server_only?(name)
36
+ manifest = load_manifest
37
+ manifest.dig(name, "serverOnly") == true
38
+ end
39
+ end
40
+
41
+ # Import Map タグを生成する
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
90
+
91
+ html = <<~HTML
92
+ <script type="importmap">
93
+ #{default_map.to_json}
94
+ </script>
95
+ HTML
96
+
97
+ html.respond_to?(:html_safe) ? html.html_safe : html
98
+ end
99
+
100
+ # Island コンポーネントをレンダリングする
101
+ #
102
+ # SSR が有効な場合はサーバーサイドで HTML を生成し、
103
+ # クライアントサイドでハイドレーションを行います。
104
+ #
105
+ # @param name [String] コンポーネント名 (例: "Counter")
106
+ # @param props [Hash] コンポーネントに渡すプロパティ
107
+ # @param options [Hash] オプション
108
+ # @option options [String] :id 要素のID
109
+ # @option options [String] :tag ラッパータグ (デフォルト: div)
110
+ # @option options [Boolean] :ssr SSR を有効にするか (デフォルト: auto)
111
+ # @option options [Boolean] :hydrate クライアントサイドでハイドレーションするか (デフォルト: true)
112
+ # @return [String] レンダリングされた HTML
113
+ #
114
+ # @example 基本的な使用法
115
+ # <%= island "Counter", count: 5 %>
116
+ #
117
+ # @example SSR を明示的に無効化
118
+ # <%= island "HeavyChart", data: @data, ssr: false %>
119
+ #
120
+ # @example ハイドレーションを無効化 (静的 HTML のみ)
121
+ # <%= island "StaticCard", title: "Hello", hydrate: false %>
122
+ #
123
+ def island(name, props = {}, options = {})
124
+ tag_name = options.delete(:tag) || :div
125
+
126
+ # デフォルトの hydrate 値を決定
127
+ # serverOnly (app/pages) の場合はデフォルトで false
128
+ default_hydrate = !Island.server_only?(name)
129
+ hydrate = options.fetch(:hydrate, default_hydrate)
130
+
131
+ # SSR 有効/無効の判定
132
+ # 1. options[:ssr] が明示的に指定されていればそれを使う
133
+ # 2. マニフェストで "client only" ならば SSR 無効
134
+ # 3. デフォルトは SSR 有効
135
+ ssr_enabled = if options.key?(:ssr)
136
+ options[:ssr]
137
+ else
138
+ !Island.client_only?(name)
139
+ end
140
+
141
+ # 開発モードかどうか
142
+ development = defined?(Salvia.env) ? Salvia.env == "development" : true
143
+
144
+ # SSR でコンテンツを生成
145
+ inner_html = ""
146
+ begin
147
+ if ssr_enabled && defined?(Salvia::SSR) && Salvia::SSR.respond_to?(:configured?) && Salvia::SSR.configured?
148
+ inner_html = Salvia::SSR.render(name, props)
149
+ end
150
+ rescue => e
151
+ # SSR 失敗時はエラーをログに出力し、CSR にフォールバック
152
+ if development
153
+ inner_html = ssr_error_inline(name, e.message)
154
+ end
155
+ end
156
+
157
+ # データ属性を構築
158
+ data_attrs = {}
159
+
160
+ if hydrate
161
+ data_attrs[:island] = name
162
+ data_attrs[:props] = props.to_json
163
+ end
164
+
165
+ # 開発モードではデバッグ用の属性を追加
166
+ if development
167
+ data_attrs[:salvia_debug] = true
168
+ data_attrs[:salvia_component] = name
169
+ end
170
+
171
+ # HTML オプションを構築
172
+ html_options = options.dup
173
+ html_options.delete(:ssr)
174
+ html_options.delete(:hydrate)
175
+ html_options[:data] = (html_options[:data] || {}).merge(data_attrs)
176
+
177
+ # 開発モードではインスペクター用のクラスを追加
178
+ if development
179
+ html_options[:class] = [html_options[:class], "salvia-island"].compact.join(" ")
180
+ end
181
+
182
+ result = build_tag(tag_name, html_options) { inner_html }
183
+ result.respond_to?(:html_safe) ? result.html_safe : result
184
+ end
185
+
186
+ # ページコンポーネントをレンダリングする (Full JSX Architecture用)
187
+ #
188
+ # <!DOCTYPE html> を付与し、ルート要素としてレンダリングします。
189
+ # また、<head> タグが存在する場合、自動的に Import Map を注入します。
190
+ #
191
+ # @param name [String] ページコンポーネント名 (例: "pages/Home")
192
+ # @param props [Hash] プロパティ
193
+ # @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
205
+ result.respond_to?(:html_safe) ? result.html_safe : result
206
+ end
207
+
208
+ # 後方互換性のため
209
+ alias_method :salvia_page, :ssr
210
+
211
+ private
212
+
213
+ def build_tag(name, options)
214
+ content = block_given? ? yield : ""
215
+ attrs = options.map do |key, value|
216
+ if key == :data && value.is_a?(Hash)
217
+ value.map { |k, v| " data-#{k.to_s.gsub('_', '-')}=\"#{escape_html(v)}\"" }.join
218
+ else
219
+ " #{key}=\"#{escape_html(value)}\""
220
+ end
221
+ end.join
222
+
223
+ "<#{name}#{attrs}>#{content}</#{name}>"
224
+ end
225
+
226
+ # インラインエラー表示 (開発モード用、軽量版)
227
+ def ssr_error_inline(name, message)
228
+ <<~HTML
229
+ <div style="
230
+ background: #fee;
231
+ border: 1px solid #fcc;
232
+ border-radius: 4px;
233
+ padding: 8px 12px;
234
+ font-size: 12px;
235
+ color: #900;
236
+ ">
237
+ <strong>⚠️ SSR Error:</strong> #{escape_html(name)} - #{escape_html(message)}
238
+ </div>
239
+ HTML
240
+ end
241
+
242
+ def escape_html(str)
243
+ str.to_s
244
+ .gsub("&", "&amp;")
245
+ .gsub("<", "&lt;")
246
+ .gsub(">", "&gt;")
247
+ .gsub('"', "&quot;")
248
+ end
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Salvia
4
+ module Helpers
5
+ module Tag
6
+ # HTML タグを生成する
7
+ #
8
+ # @param name [Symbol, String] タグ名
9
+ # @param options [Hash] 属性
10
+ # @param block [Proc] コンテンツブロック
11
+ # @return [String] HTML 文字列
12
+ def tag(name, options = {}, &block)
13
+ html_options = options.map do |key, value|
14
+ next if value.nil?
15
+
16
+ if value.is_a?(Hash) && key == :data
17
+ value.map { |k, v| %(data-#{k.to_s.gsub("_", "-")}="#{escape_html(v)}") }.join(" ")
18
+ elsif value == true
19
+ key
20
+ else
21
+ %(#{key}="#{escape_html(value)}")
22
+ end
23
+ end.compact.join(" ")
24
+
25
+ html_options = " " + html_options unless html_options.empty?
26
+
27
+ if block_given?
28
+ content = block.call
29
+ "<#{name}#{html_options}>#{content}</#{name}>"
30
+ else
31
+ "<#{name}#{html_options} />"
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def escape_html(str)
38
+ str.to_s
39
+ .gsub("&", "&amp;")
40
+ .gsub("<", "&lt;")
41
+ .gsub(">", "&gt;")
42
+ .gsub('"', "&quot;")
43
+ .gsub("'", "&#39;")
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Salvia
4
+ module Helpers
5
+ include Tag
6
+ include Island
7
+ end
8
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Salvia
4
+ class Railtie < ::Rails::Railtie
5
+ initializer "salvia.helpers" do
6
+ ActiveSupport.on_load(:action_view) do
7
+ include Salvia::Helpers
8
+ end
9
+
10
+ ActiveSupport.on_load(:action_controller) do
11
+ include Salvia::Helpers
12
+ end
13
+
14
+ ActiveSupport.on_load(:action_controller_api) do
15
+ include Salvia::Helpers
16
+ end
17
+ end
18
+
19
+ initializer "salvia.configure" do |app|
20
+ # Default configuration for Rails
21
+ Salvia.configure do |config|
22
+ # Use Rails logger
23
+ Salvia.logger = Rails.logger
24
+ end
25
+ end
26
+
27
+ initializer "salvia.middleware" do |app|
28
+ if Rails.env.development?
29
+ app.middleware.use Salvia::Server::DevServer
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,83 @@
1
+ module Salvia
2
+ module Server
3
+ class DevServer
4
+ def initialize(app)
5
+ @app = app
6
+ end
7
+
8
+ def call(env)
9
+ # Only active in development
10
+ unless ENV["RACK_ENV"] == "development" || ENV["RAILS_ENV"] == "development"
11
+ return @app.call(env)
12
+ end
13
+
14
+ request = Rack::Request.new(env)
15
+
16
+ if request.path.start_with?("/salvia/assets/")
17
+ handle_asset_request(request)
18
+ else
19
+ @app.call(env)
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def handle_asset_request(request)
26
+ # /salvia/assets/islands/Counter.js -> islands/Counter.tsx
27
+ path_info = request.path.sub("/salvia/assets/", "")
28
+
29
+ # Special handling for islands.js (Client Entry)
30
+ if path_info == "javascripts/islands.js"
31
+ return serve_islands_js
32
+ end
33
+
34
+ # Remove .js extension to find source
35
+ base_name = path_info.sub(/\.js$/, "")
36
+
37
+ source_path = resolve_source_path(base_name)
38
+
39
+ unless source_path
40
+ return [404, { "content-type" => "text/plain" }, ["Not Found: #{path_info}"]]
41
+ end
42
+
43
+ begin
44
+ # Bundle for browser (ESM)
45
+ # We externalize dependencies that should be handled by Import Map
46
+ externals = Salvia::Core::ImportMap.new.keys
47
+ # Always externalize framework aliases just in case
48
+ externals += ["framework", "framework/hooks", "framework/jsx-runtime"]
49
+
50
+ js_code = Salvia::Compiler.bundle(
51
+ source_path,
52
+ externals: externals.uniq,
53
+ format: "esm"
54
+ )
55
+
56
+ [200, { "content-type" => "application/javascript" }, [js_code]]
57
+ rescue => e
58
+ [500, { "content-type" => "text/plain" }, ["Build Error: #{e.message}"]]
59
+ end
60
+ end
61
+
62
+ def resolve_source_path(name)
63
+ Salvia::Core::PathResolver.resolve(name)
64
+ end
65
+
66
+ def serve_islands_js
67
+ # Check user's islands.js
68
+ user_path = File.join(Salvia.root, "salvia/assets/javascripts/islands.js")
69
+ if File.exist?(user_path)
70
+ return [200, { "content-type" => "application/javascript" }, [File.read(user_path)]]
71
+ end
72
+
73
+ # Fallback to internal islands.js
74
+ internal_path = File.expand_path("../../../assets/javascripts/islands.js", __dir__)
75
+ if File.exist?(internal_path)
76
+ return [200, { "content-type" => "application/javascript" }, [File.read(internal_path)]]
77
+ end
78
+
79
+ [404, { "content-type" => "text/plain" }, ["islands.js not found"]]
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,136 @@
1
+ require 'net/http'
2
+ require 'uri'
3
+ require 'json'
4
+ require 'timeout'
5
+
6
+ module Salvia
7
+ module Server
8
+ class Sidecar
9
+ SCRIPT_PATH = File.join(__dir__, "sidecar.ts")
10
+
11
+ def self.instance
12
+ @instance ||= new
13
+ end
14
+
15
+ def initialize
16
+ @pid = nil
17
+ @port = nil
18
+ at_exit { stop }
19
+ end
20
+
21
+ def start
22
+ return if running?
23
+
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}"
44
+ end
45
+ rescue IOError
46
+ # Stream closed
47
+ end
48
+ end
49
+ end
50
+
51
+ def stop
52
+ return unless @pid
53
+ Process.kill("TERM", @pid)
54
+ @pid = nil
55
+ @port = nil
56
+ @io.close if @io && !@io.closed?
57
+ end
58
+
59
+ def running?
60
+ return false unless @pid
61
+ Process.getpgid(@pid)
62
+ true
63
+ rescue Errno::ESRCH
64
+ false
65
+ end
66
+
67
+ def bundle(entry_point, externals: [], format: "esm", global_name: nil, config_path: nil)
68
+ start unless running?
69
+
70
+ resolved_config_path = File.expand_path(config_path || Salvia.config.deno_config_path, Salvia.root)
71
+ puts "[Salvia] Bundle config path: #{resolved_config_path}"
72
+
73
+ response = request("bundle", {
74
+ entryPoint: entry_point,
75
+ externals: externals,
76
+ format: format,
77
+ globalName: global_name,
78
+ configPath: resolved_config_path
79
+ })
80
+ if response["error"]
81
+ raise "Sidecar Bundle Error: #{response["error"]}"
82
+ end
83
+ response["code"]
84
+ end
85
+
86
+ def check(entry_point, config_path: nil)
87
+ start unless running?
88
+ resolved_config_path = File.expand_path(config_path || Salvia.config.deno_config_path, Salvia.root)
89
+ request("check", { entryPoint: entry_point, configPath: resolved_config_path })
90
+ end
91
+
92
+ def fmt(entry_point, config_path: nil)
93
+ start unless running?
94
+ resolved_config_path = File.expand_path(config_path || Salvia.config.deno_config_path, Salvia.root)
95
+ request("fmt", { entryPoint: entry_point, configPath: resolved_config_path })
96
+ end
97
+
98
+ private
99
+
100
+ def wait_for_port
101
+ Timeout.timeout(10) do
102
+ 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
108
+ end
109
+ end
110
+ end
111
+ rescue Timeout::Error
112
+ stop
113
+ raise "Sidecar failed to start (Timeout waiting for port)"
114
+ end
115
+
116
+ def request(command, params = {})
117
+ uri = URI("http://localhost:#{@port}/")
118
+ http = Net::HTTP.new(uri.host, uri.port)
119
+
120
+ request = Net::HTTP::Post.new(uri)
121
+ request.content_type = 'application/json'
122
+ request.body = { command: command, params: params }.to_json
123
+
124
+ response = http.request(request)
125
+
126
+ if response.is_a?(Net::HTTPSuccess)
127
+ JSON.parse(response.body)
128
+ else
129
+ raise "Sidecar Request Failed: #{response.code} #{response.message}"
130
+ end
131
+ rescue => e
132
+ raise "Sidecar Request Error: #{e.message}"
133
+ end
134
+ end
135
+ end
136
+ end