salvia_rb 0.1.5

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,431 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+ require "rack/session"
5
+ require "securerandom"
6
+
7
+ module Salvia
8
+ # HTTP リクエストを処理する Rack アプリケーション
9
+ #
10
+ # ゼロコンフィグで動作可能。config.ru は以下だけで OK:
11
+ #
12
+ # @example 最小構成 (config.ru)
13
+ # require "salvia_rb"
14
+ # run Salvia::Application.new
15
+ #
16
+ # @example カスタム構成
17
+ # require "salvia_rb"
18
+ # Salvia.root = File.expand_path(__dir__)
19
+ # Salvia.configure { |c| c.ssr_bundle_path = "custom/path.js" }
20
+ # run Salvia::Application.new
21
+ #
22
+ class Application
23
+ def initialize
24
+ auto_setup!
25
+ end
26
+
27
+ def call(env)
28
+ # アプリをミドルウェアスタックでラップして呼び出し
29
+ @stack.call(env)
30
+ end
31
+
32
+ private
33
+
34
+ # ゼロコンフィグ自動セットアップ
35
+ def auto_setup!
36
+ detect_root!
37
+ load_dotenv!
38
+ load_app_config!
39
+ setup_environment!
40
+ setup_database!
41
+ setup_autoloader!
42
+ load_routes!
43
+ build_middleware_stack!
44
+ end
45
+
46
+ # アプリケーションルートを自動検出
47
+ def detect_root!
48
+ return if Salvia.root && Salvia.root != Dir.pwd
49
+
50
+ # config.ru からの相対パスを検出
51
+ caller_locations.each do |loc|
52
+ if loc.path.end_with?("config.ru")
53
+ Salvia.root = File.dirname(File.expand_path(loc.path))
54
+ return
55
+ end
56
+ end
57
+
58
+ # フォールバック: カレントディレクトリ
59
+ Salvia.root ||= Dir.pwd
60
+ end
61
+
62
+ # .env ファイルを読み込み(開発環境のみ)
63
+ def load_dotenv!
64
+ return if Salvia.production?
65
+
66
+ env_files = [
67
+ File.join(Salvia.root, ".env.#{Salvia.env}.local"),
68
+ File.join(Salvia.root, ".env.#{Salvia.env}"),
69
+ File.join(Salvia.root, ".env.local"),
70
+ File.join(Salvia.root, ".env")
71
+ ].select { |f| File.exist?(f) }
72
+
73
+ return if env_files.empty?
74
+
75
+ begin
76
+ require "dotenv"
77
+ Dotenv.load(*env_files)
78
+ rescue LoadError
79
+ # dotenv がなければスキップ
80
+ end
81
+ end
82
+
83
+ # config/app.rb を読み込み
84
+ def load_app_config!
85
+ app_config = File.join(Salvia.root, "config", "app.rb")
86
+ require app_config if File.exist?(app_config)
87
+ end
88
+
89
+ # 環境設定を読み込み
90
+ def setup_environment!
91
+ # デフォルトロガー設定
92
+ setup_default_logger!
93
+ end
94
+
95
+ def setup_default_logger!
96
+ return if Salvia.instance_variable_get(:@logger)
97
+
98
+ if Salvia.development?
99
+ Salvia.logger = Logger.new($stdout)
100
+ Salvia.logger.level = Logger::DEBUG
101
+ else
102
+ log_dir = File.join(Salvia.root, "log")
103
+ FileUtils.mkdir_p(log_dir)
104
+ Salvia.logger = Logger.new(File.join(log_dir, "#{Salvia.env}.log"))
105
+ Salvia.logger.level = Logger::INFO
106
+ end
107
+ end
108
+
109
+ # データベースを自動セットアップ(規約ベース)
110
+ def setup_database!
111
+ return unless database_available?
112
+
113
+ if database_config_exists?
114
+ Salvia::Database.setup!
115
+ else
116
+ setup_default_database!
117
+ end
118
+ rescue StandardError => e
119
+ Salvia.logger.warn "データベース接続をスキップ: #{e.message}"
120
+ end
121
+
122
+ def database_available?
123
+ defined?(ActiveRecord) && Dir.exist?(File.join(Salvia.root, "db"))
124
+ end
125
+
126
+ def database_config_exists?
127
+ File.exist?(File.join(Salvia.root, "config", "database.yml"))
128
+ end
129
+
130
+ # 規約ベースのデフォルトデータベース設定
131
+ def setup_default_database!
132
+ db_path = File.join(Salvia.root, "db", "#{Salvia.env}.sqlite3")
133
+ FileUtils.mkdir_p(File.dirname(db_path))
134
+
135
+ ActiveRecord::Base.establish_connection(
136
+ adapter: "sqlite3",
137
+ database: db_path
138
+ )
139
+
140
+ ActiveRecord::Base.logger = Logger.new($stdout) if Salvia.development?
141
+ end
142
+
143
+ # Zeitwerk autoloader を自動セットアップ
144
+ def setup_autoloader!
145
+ return if Salvia.app_loader
146
+
147
+ loader = Zeitwerk::Loader.new
148
+
149
+ # app 以下の標準ディレクトリを自動登録
150
+ %w[controllers models components].each do |dir|
151
+ path = File.join(Salvia.root, "app", dir)
152
+ loader.push_dir(path) if Dir.exist?(path)
153
+ end
154
+
155
+ loader.enable_reloading if Salvia.development?
156
+ loader.setup
157
+ Salvia.app_loader = loader
158
+ end
159
+
160
+ # ルートファイルを読み込み
161
+ def load_routes!
162
+ routes_file = File.join(Salvia.root, "config", "routes.rb")
163
+
164
+ if File.exist?(routes_file)
165
+ require routes_file
166
+ elsif Salvia::Router.instance.routes.empty?
167
+ raise Salvia::Error, <<~ERROR
168
+ No routes defined!
169
+
170
+ Create config/routes.rb:
171
+
172
+ Salvia::Router.draw do
173
+ root to: "home#index"
174
+ end
175
+ ERROR
176
+ end
177
+ end
178
+
179
+ # ミドルウェアスタックを構築
180
+ def build_middleware_stack!
181
+ app = RackApp.new
182
+
183
+ # 内側から外側へ積み上げ
184
+ @stack = app
185
+ @stack = build_logging_middleware(@stack)
186
+ @stack = build_csrf_middleware(@stack)
187
+ @stack = build_session_middleware(@stack)
188
+ @stack = build_static_middleware(@stack)
189
+ end
190
+
191
+ # 静的ファイル配信ミドルウェア
192
+ def build_static_middleware(app)
193
+ return app unless Salvia.config.static_files_enabled
194
+
195
+ # Salvia 内部アセット
196
+ app = Salvia::AssetsMiddleware.new(app)
197
+
198
+ # public/assets
199
+ public_dir = File.join(Salvia.root, "public")
200
+ if Dir.exist?(public_dir)
201
+ app = Rack::Static.new(app,
202
+ urls: ["/assets"],
203
+ root: public_dir,
204
+ header_rules: [[:all, { "cache-control" => "public, max-age=31536000" }]]
205
+ )
206
+ end
207
+
208
+ # vendor/client (Islands クライアント)
209
+ client_dir = File.join(Salvia.root, "vendor", "client")
210
+ if Dir.exist?(client_dir)
211
+ app = ClientAssetsMiddleware.new(app, client_dir)
212
+ end
213
+
214
+ app
215
+ end
216
+
217
+ # セッションミドルウェア
218
+ def build_session_middleware(app)
219
+ Rack::Session::Cookie.new(app,
220
+ key: Salvia.config.session_key_value,
221
+ secret: Salvia.config.session_secret_value,
222
+ same_site: :lax,
223
+ secure: Salvia.production?
224
+ )
225
+ end
226
+
227
+ # CSRF 保護ミドルウェア
228
+ def build_csrf_middleware(app)
229
+ return app unless Salvia.config.csrf_enabled
230
+ return app unless defined?(Salvia::CSRF::Protection)
231
+ Salvia::CSRF::Protection.new(app)
232
+ end
233
+
234
+ # ロギングミドルウェア
235
+ def build_logging_middleware(app)
236
+ Rack::CommonLogger.new(app, Salvia.logger)
237
+ end
238
+
239
+ # 内部 Rack アプリ(リクエスト処理)
240
+ class RackApp
241
+ def call(env)
242
+ if Salvia.development? && Salvia.app_loader
243
+ Salvia.app_loader.reload
244
+ end
245
+
246
+ request = Rack::Request.new(env)
247
+ response = Rack::Response.new
248
+
249
+ begin
250
+ handle_request(request, response)
251
+ rescue StandardError => e
252
+ handle_error(e, request, response)
253
+ end
254
+
255
+ response.finish
256
+ end
257
+
258
+ private
259
+
260
+ def handle_request(request, response)
261
+ result = Router.recognize(request)
262
+
263
+ if result
264
+ controller_class, action, route_params = result
265
+ controller = controller_class.new(request, response, route_params)
266
+ controller.process(action)
267
+ else
268
+ render_not_found(response)
269
+ end
270
+ end
271
+
272
+ def handle_error(error, request, response)
273
+ Salvia.logger.error "#{error.class}: #{error.message}"
274
+ Salvia.logger.error error.backtrace&.first(10)&.join("\n")
275
+
276
+ if Salvia.development?
277
+ render_development_error(error, request, response)
278
+ else
279
+ render_production_error(response)
280
+ end
281
+ end
282
+
283
+ def render_not_found(response)
284
+ response.status = 404
285
+ response["content-type"] = "text/html; charset=utf-8"
286
+
287
+ if Salvia.development?
288
+ response.write(not_found_development_html)
289
+ else
290
+ response.write(public_file_content("404.html") || not_found_production_html)
291
+ end
292
+ end
293
+
294
+ def render_development_error(error, request, response)
295
+ response.status = 500
296
+ response["content-type"] = "text/html; charset=utf-8"
297
+ response.write(development_error_html(error, request))
298
+ end
299
+
300
+ def render_production_error(response)
301
+ response.status = 500
302
+ response["content-type"] = "text/html; charset=utf-8"
303
+ response.write(public_file_content("500.html") || production_error_html)
304
+ end
305
+
306
+ def public_file_content(filename)
307
+ path = File.join(Salvia.root, "public", filename)
308
+ File.read(path) if File.exist?(path)
309
+ end
310
+
311
+ def not_found_development_html
312
+ <<~HTML
313
+ <!DOCTYPE html>
314
+ <html>
315
+ <head>
316
+ <title>404 Not Found - Salvia</title>
317
+ <style>
318
+ body { font-family: system-ui, sans-serif; max-width: 800px; margin: 50px auto; padding: 20px; }
319
+ h1 { color: #6A5ACD; }
320
+ pre { background: #f5f5f5; padding: 15px; border-radius: 8px; overflow-x: auto; }
321
+ .routes { margin-top: 20px; }
322
+ .route { padding: 8px; border-bottom: 1px solid #eee; font-family: monospace; }
323
+ </style>
324
+ </head>
325
+ <body>
326
+ <h1>🌿 404 Not Found</h1>
327
+ <p>このリクエストにマッチするルートがありません。</p>
328
+ <div class="routes">
329
+ <h3>登録されているルート:</h3>
330
+ #{routes_list_html}
331
+ </div>
332
+ </body>
333
+ </html>
334
+ HTML
335
+ end
336
+
337
+ def routes_list_html
338
+ Router.instance.routes.map do |route|
339
+ %(<div class="route">#{route.method.to_s.upcase.ljust(7)} #{route.pattern} → #{route.controller}##{route.action}</div>)
340
+ end.join("\n")
341
+ end
342
+
343
+ def not_found_production_html
344
+ <<~HTML
345
+ <!DOCTYPE html>
346
+ <html>
347
+ <head><title>404 Not Found</title></head>
348
+ <body>
349
+ <h1>404 Not Found</h1>
350
+ <p>お探しのページは見つかりませんでした。</p>
351
+ </body>
352
+ </html>
353
+ HTML
354
+ end
355
+
356
+ def development_error_html(error, request)
357
+ backtrace = error.backtrace&.first(20)&.map { |line| "<div>#{Rack::Utils.escape_html(line)}</div>" }&.join || ""
358
+
359
+ <<~HTML
360
+ <!DOCTYPE html>
361
+ <html>
362
+ <head>
363
+ <title>Error - Salvia</title>
364
+ <style>
365
+ body { font-family: system-ui, sans-serif; max-width: 1000px; margin: 50px auto; padding: 20px; }
366
+ h1 { color: #dc2626; }
367
+ .error-class { color: #6A5ACD; font-size: 1.2em; }
368
+ .error-message { background: #fef2f2; border: 1px solid #fecaca; padding: 15px; border-radius: 8px; margin: 15px 0; }
369
+ .backtrace { background: #1e1e1e; color: #d4d4d4; padding: 15px; border-radius: 8px; font-family: monospace; font-size: 13px; overflow-x: auto; }
370
+ .backtrace div { padding: 2px 0; }
371
+ .request-info { margin-top: 20px; background: #f5f5f5; padding: 15px; border-radius: 8px; }
372
+ .request-info dt { font-weight: bold; margin-top: 10px; }
373
+ .request-info dd { margin-left: 0; font-family: monospace; }
374
+ </style>
375
+ </head>
376
+ <body>
377
+ <h1>🔥 #{Rack::Utils.escape_html(error.class.name)}</h1>
378
+ <div class="error-message">#{Rack::Utils.escape_html(error.message)}</div>
379
+
380
+ <h3>バックトレース</h3>
381
+ <div class="backtrace">#{backtrace}</div>
382
+
383
+ <div class="request-info">
384
+ <h3>リクエスト情報</h3>
385
+ <dl>
386
+ <dt>メソッド</dt><dd>#{request.request_method}</dd>
387
+ <dt>パス</dt><dd>#{Rack::Utils.escape_html(request.path_info)}</dd>
388
+ <dt>パラメータ</dt><dd>#{Rack::Utils.escape_html(request.params.inspect)}</dd>
389
+ </dl>
390
+ </div>
391
+ </body>
392
+ </html>
393
+ HTML
394
+ end
395
+
396
+ def production_error_html
397
+ <<~HTML
398
+ <!DOCTYPE html>
399
+ <html>
400
+ <head><title>500 Internal Server Error</title></head>
401
+ <body>
402
+ <h1>500 Internal Server Error</h1>
403
+ <p>エラーが発生しました。しばらくしてからもう一度お試しください。</p>
404
+ </body>
405
+ </html>
406
+ HTML
407
+ end
408
+ end
409
+
410
+ # Islands クライアントアセット配信ミドルウェア
411
+ class ClientAssetsMiddleware
412
+ def initialize(app, client_dir)
413
+ @app = app
414
+ @file_server = Rack::Files.new(client_dir)
415
+ end
416
+
417
+ def call(env)
418
+ path = env["PATH_INFO"]
419
+ if path.start_with?("/client/")
420
+ env = env.dup
421
+ env["PATH_INFO"] = path.sub("/client", "")
422
+ status, headers, body = @file_server.call(env)
423
+ headers["content-type"] = "application/javascript" if status == 200
424
+ [status, headers, body]
425
+ else
426
+ @app.call(env)
427
+ end
428
+ end
429
+ end
430
+ end
431
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+ require "fileutils"
6
+ require "pathname"
7
+
8
+ module Salvia
9
+ module Assets
10
+ MANIFEST_PATH = "public/assets/manifest.json"
11
+
12
+ class << self
13
+ def path(source)
14
+ return "/assets/#{source}" if Salvia.development? || Salvia.test?
15
+
16
+ manifest[source] || "/assets/#{source}"
17
+ end
18
+
19
+ def manifest
20
+ @manifest ||= load_manifest
21
+ end
22
+
23
+ def load_manifest
24
+ path = File.join(Salvia.root, MANIFEST_PATH)
25
+ if File.exist?(path)
26
+ JSON.parse(File.read(path))
27
+ else
28
+ {}
29
+ end
30
+ end
31
+
32
+ # マニフェストをリセット(テスト用など)
33
+ def reset!
34
+ @manifest = nil
35
+ end
36
+
37
+ # アセットのプリコンパイル(CLI用)
38
+ def precompile!
39
+ manifest = {}
40
+ assets_dir = File.join(Salvia.root, "public", "assets")
41
+ target_dir = assets_dir # 同じディレクトリにハッシュ付きファイルを置く
42
+
43
+ Dir.glob("#{assets_dir}/**/*").each do |file|
44
+ next if File.directory?(file)
45
+ next if file.end_with?(".json") # マニフェスト自体はスキップ
46
+ next if File.basename(file).match?(/^[a-f0-9]{64}\./) # 既にハッシュ付きならスキップ(簡易判定)
47
+
48
+ # 相対パスを取得 (e.g., "stylesheets/tailwind.css")
49
+ relative_path = Pathname.new(file).relative_path_from(Pathname.new(assets_dir)).to_s
50
+
51
+ # ハッシュ計算
52
+ content = File.read(file)
53
+ hash = Digest::SHA256.hexdigest(content)[0...8]
54
+ ext = File.extname(file)
55
+ basename = File.basename(file, ext)
56
+
57
+ hashed_filename = "#{basename}-#{hash}#{ext}"
58
+ hashed_relative_path = File.join(File.dirname(relative_path), hashed_filename)
59
+
60
+ # ファイルコピー
61
+ FileUtils.cp(file, File.join(target_dir, hashed_relative_path))
62
+
63
+ # マニフェストに追加
64
+ # key: "stylesheets/tailwind.css", value: "/assets/stylesheets/tailwind-123...css"
65
+ manifest[relative_path] = "/assets/#{hashed_relative_path}"
66
+ end
67
+
68
+ # マニフェスト保存
69
+ File.write(File.join(Salvia.root, MANIFEST_PATH), JSON.pretty_generate(manifest))
70
+ puts "✨ Assets precompiled to #{MANIFEST_PATH}"
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Salvia
4
+ # Salvia 内部アセットを配信するミドルウェア
5
+ # islands.js など、gem に含まれるファイルをユーザーに見せずに配信
6
+ class AssetsMiddleware
7
+ ASSETS = {
8
+ "/assets/javascripts/islands.js" => {
9
+ path: File.expand_path("../../assets/javascripts/islands.js", __dir__),
10
+ content_type: "application/javascript"
11
+ }
12
+ }.freeze
13
+
14
+ def initialize(app)
15
+ @app = app
16
+ end
17
+
18
+ def call(env)
19
+ path = env["PATH_INFO"]
20
+
21
+ if (asset = ASSETS[path])
22
+ serve_asset(asset)
23
+ else
24
+ @app.call(env)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def serve_asset(asset)
31
+ if File.exist?(asset[:path])
32
+ content = File.read(asset[:path])
33
+ [
34
+ 200,
35
+ {
36
+ "content-type" => asset[:content_type],
37
+ "cache-control" => "public, max-age=31536000"
38
+ },
39
+ [content]
40
+ ]
41
+ else
42
+ [404, { "content-type" => "text/plain" }, ["Asset not found"]]
43
+ end
44
+ end
45
+ end
46
+ end