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.
- checksums.yaml +7 -0
- data/LICENSE.txt +22 -0
- data/README.md +235 -0
- data/assets/javascripts/islands.js +26 -0
- data/assets/scripts/build_ssr.ts +261 -0
- data/exe/salvia +7 -0
- data/lib/salvia_rb/application.rb +431 -0
- data/lib/salvia_rb/assets.rb +74 -0
- data/lib/salvia_rb/assets_middleware.rb +46 -0
- data/lib/salvia_rb/cli.rb +1398 -0
- data/lib/salvia_rb/component.rb +74 -0
- data/lib/salvia_rb/controller.rb +277 -0
- data/lib/salvia_rb/csrf.rb +130 -0
- data/lib/salvia_rb/database.rb +176 -0
- data/lib/salvia_rb/flash.rb +43 -0
- data/lib/salvia_rb/helpers/component.rb +31 -0
- data/lib/salvia_rb/helpers/csrf.rb +47 -0
- data/lib/salvia_rb/helpers/inspector.rb +192 -0
- data/lib/salvia_rb/helpers/island.rb +143 -0
- data/lib/salvia_rb/helpers/tag.rb +90 -0
- data/lib/salvia_rb/helpers.rb +11 -0
- data/lib/salvia_rb/plugins/base.rb +59 -0
- data/lib/salvia_rb/router.rb +181 -0
- data/lib/salvia_rb/ssr/quickjs.rb +404 -0
- data/lib/salvia_rb/ssr.rb +119 -0
- data/lib/salvia_rb/test.rb +20 -0
- data/lib/salvia_rb/version.rb +5 -0
- data/lib/salvia_rb.rb +250 -0
- metadata +344 -0
|
@@ -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
|