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.
@@ -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
data/lib/salvia/ssr.rb CHANGED
@@ -62,11 +62,20 @@ module Salvia
62
62
 
63
63
  # SSR エンジンを設定
64
64
  # @param options [Hash] エンジンオプション
65
+ # @option options [Symbol] :engine エンジン名 (:quickjs)
65
66
  # @option options [String] :bundle_path SSR バンドルのパス
66
67
  # @option options [Boolean] :development 開発モード
67
68
  def configure(options = {})
68
- require_relative "ssr/quickjs"
69
- @current_adapter = QuickJS.new(options)
69
+ engine = options.fetch(:engine, :quickjs)
70
+
71
+ case engine
72
+ when :quickjs
73
+ require_relative "ssr/quickjs"
74
+ @current_adapter = QuickJS.new(options)
75
+ else
76
+ raise EngineNotFoundError, "Unknown SSR engine: #{engine}"
77
+ end
78
+
70
79
  @current_adapter.setup!
71
80
  @current_adapter
72
81
  end
@@ -76,19 +85,45 @@ module Salvia
76
85
  # @param props [Hash] プロパティ
77
86
  # @return [String] レンダリングされた HTML
78
87
  def render(component_name, props = {})
79
- raise Error, "SSR not configured. Call Salvia::SSR.configure first." unless current_adapter
88
+ ensure_configured!
80
89
  current_adapter.render(component_name, props)
81
90
  end
82
91
 
92
+ # ページコンポーネントをレンダリング (Import Map 注入 + DOCTYPE)
93
+ # @param component_name [String] コンポーネント名
94
+ # @param props [Hash] プロパティ
95
+ # @param options [Hash] オプション
96
+ # @return [String] 完全な HTML 文字列
97
+ def render_page(component_name, props = {}, options = {})
98
+ html = render(component_name, props)
99
+
100
+ # <head> がある場合、Import Map を自動注入
101
+ if html.include?("</head>")
102
+ map = Salvia::Core::ImportMap.generate
103
+ import_map_html = <<~HTML
104
+ <script type="importmap">
105
+ #{map.to_json}
106
+ </script>
107
+ HTML
108
+ html = html.sub("</head>", "#{import_map_html}</head>")
109
+ end
110
+
111
+ if options.fetch(:doctype, true)
112
+ html = "<!DOCTYPE html>\n" + html
113
+ end
114
+
115
+ html
116
+ end
117
+
83
118
  # コンポーネントを登録
84
119
  def register_component(name, code)
85
- raise Error, "SSR not configured. Call Salvia::SSR.configure first." unless current_adapter
120
+ ensure_configured!
86
121
  current_adapter.register_component(name, code)
87
122
  end
88
123
 
89
124
  # バンドルをリロード (開発モード用)
90
125
  def reload!
91
- return unless current_adapter
126
+ return unless configured?
92
127
  current_adapter.reload_bundle! if current_adapter.respond_to?(:reload_bundle!)
93
128
  end
94
129
 
@@ -98,6 +133,16 @@ module Salvia
98
133
  current_adapter.last_build_error = error if current_adapter&.respond_to?(:last_build_error=)
99
134
  end
100
135
 
136
+ # ビルドエラーを取得
137
+ def build_error
138
+ @last_build_error || (current_adapter&.respond_to?(:last_build_error) ? current_adapter.last_build_error : nil)
139
+ end
140
+
141
+ # ビルドエラーがあるか確認
142
+ def build_error?
143
+ !build_error.nil?
144
+ end
145
+
101
146
  # ビルドエラーをクリア
102
147
  def clear_build_error
103
148
  @last_build_error = nil
@@ -114,6 +159,23 @@ module Salvia
114
159
  def configured?
115
160
  !current_adapter.nil? && current_adapter.initialized?
116
161
  end
162
+
163
+ private
164
+
165
+ def ensure_configured!
166
+ return if configured?
167
+
168
+ # Auto-configure using defaults from Salvia.config
169
+ # This handles cases where Salvia.configure wasn't explicitly called
170
+ # or called before SSR module was fully loaded
171
+ configure(
172
+ bundle_path: Salvia.config.ssr_bundle_path,
173
+ development: Salvia.development?
174
+ )
175
+ rescue => e
176
+ # If auto-configuration fails, we raise the original error to avoid masking it
177
+ raise Error, "SSR not configured and auto-configuration failed: #{e.message}. Call Salvia::SSR.configure explicitly."
178
+ end
117
179
  end
118
180
  end
119
181
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Salvia
4
- VERSION = "0.2.0"
4
+ VERSION = "0.2.2"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: salvia
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hiroto Furugen
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-12-11 00:00:00.000000000 Z
11
+ date: 2025-12-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack
@@ -94,8 +94,9 @@ dependencies:
94
94
  - - "~>"
95
95
  - !ruby/object:Gem::Version
96
96
  version: '5.0'
97
- description: A standalone SSR engine for Ruby, bringing Islands Architecture to any
98
- Rack application without Node.js.
97
+ description: A standalone SSR engine for Ruby that brings Islands Architecture to
98
+ any Rack application. Write JSX/TSX components and render them server-side without
99
+ Node.js, JSON APIs, or complex build steps.
99
100
  email:
100
101
  - hiro_genfuru0119@icloud.com
101
102
  executables:
@@ -103,8 +104,10 @@ executables:
103
104
  extensions: []
104
105
  extra_rdoc_files: []
105
106
  files:
107
+ - ".vscode/settings.json"
108
+ - Gemfile
106
109
  - LICENSE.txt
107
- - README.md
110
+ - Rakefile
108
111
  - assets/components/Island.tsx
109
112
  - assets/islands/Counter.tsx
110
113
  - assets/javascripts/islands.js
@@ -114,6 +117,8 @@ files:
114
117
  - assets/scripts/deno.lock
115
118
  - assets/scripts/sidecar.ts
116
119
  - assets/scripts/vendor_setup.ts
120
+ - bin/console
121
+ - bin/setup
117
122
  - exe/salvia
118
123
  - lib/salvia.rb
119
124
  - lib/salvia/cli.rb
@@ -161,5 +166,5 @@ requirements: []
161
166
  rubygems_version: 3.4.19
162
167
  signing_key:
163
168
  specification_version: 4
164
- summary: Ruby Islands Architecture Engine
169
+ summary: 'Ruby Islands Architecture Engine: SSR with JSX/TSX for Rails & Rack'
165
170
  test_files: []
data/README.md DELETED
@@ -1,152 +0,0 @@
1
- # Salvia 🌿
2
-
3
- > **The Future of Rails View Layer**
4
-
5
- Salvia is a next-generation **Server-Side Rendering (SSR) engine** designed to replace ERB with **JSX/TSX** in Ruby on Rails. It brings the **Islands Architecture** and **True HTML First** philosophy to the Rails ecosystem.
6
-
7
- <img src="https://img.shields.io/gem/v/salvia?style=flat-square&color=ff6347" alt="Gem">
8
-
9
- ## Vision: The Road to Sage
10
-
11
- Salvia is the core engine for a future MVC framework called **Sage**.
12
- While Sage will be a complete standalone framework, Salvia is available *today* as a drop-in replacement for the View layer in **Ruby on Rails**.
13
-
14
- ## Features
15
-
16
- * 🏝️ **Islands Architecture**: Render interactive components (Preact/React) only where needed. Zero JS for static content.
17
- * 🚀 **True HTML First**: Replace `app/views/**/*.erb` with `app/pages/**/*.tsx`.
18
- * ⚡ **JIT Compilation**: No build steps during development. Just run `rails s`.
19
- * 💎 **Rails Native**: Seamless integration with Controllers, Routes, and Models.
20
- * 🦕 **Deno Powered**: Uses Deno for lightning-fast TypeScript compilation and formatting.
21
-
22
- ## Installation
23
-
24
- Add this line to your Rails application's Gemfile:
25
-
26
- ```ruby
27
- gem 'salvia'
28
- ```
29
-
30
- And then execute:
31
-
32
- ```bash
33
- $ bundle install
34
- ```
35
-
36
- ## Getting Started
37
-
38
- ### 1. Install Salvia
39
-
40
- Run the interactive installer to set up Salvia for your Rails project:
41
-
42
- ```bash
43
- $ bundle exec salvia install
44
- ```
45
-
46
- This creates the `salvia/` directory structure and configures your app with a **Zero Config** setup (Preact + Signals).
47
-
48
- ### 2. Create a Page (Server Component)
49
-
50
- Delete `app/views/home/index.html.erb` and create `salvia/app/pages/home/Index.tsx`:
51
-
52
- ```tsx
53
- import { h } from 'preact';
54
-
55
- export default function Home({ title }) {
56
- return (
57
- <div class="p-10">
58
- <h1 class="text-3xl font-bold">{title}</h1>
59
- <p>This is rendered on the server with 0kb JavaScript sent to the client.</p>
60
- </div>
61
- );
62
- }
63
- ```
64
-
65
- ### 3. Render in Controller
66
-
67
- In your Rails controller:
68
-
69
- ```ruby
70
- class HomeController < ApplicationController
71
- def index
72
- # Renders salvia/app/pages/home/Index.tsx
73
- render html: ssr("home/Index", title: "Hello Salvia")
74
- end
75
- end
76
- ```
77
-
78
- ### 4. Add Interactivity (Islands)
79
-
80
- Create an interactive component in `salvia/app/islands/Counter.tsx`:
81
-
82
- ```tsx
83
- import { h } from 'preact';
84
- import { useState } from 'preact/hooks';
85
-
86
- export default function Counter() {
87
- const [count, setCount] = useState(0);
88
- return (
89
- <button onClick={() => setCount(count + 1)} class="btn">
90
- Count: {count}
91
- </button>
92
- );
93
- }
94
- ```
95
-
96
- Use it in your Page:
97
-
98
- ```tsx
99
- import Counter from '../../islands/Counter.tsx';
100
-
101
- export default function Home() {
102
- return (
103
- <div>
104
- <h1>Interactive Island</h1>
105
- <Counter />
106
- </div>
107
- );
108
- }
109
- ```
110
-
111
- ### 4. Turbo Drive (Optional)
112
-
113
- Salvia works seamlessly with Turbo Drive for SPA-like navigation.
114
-
115
- Add Turbo to your layout file (e.g., `app/pages/layouts/Main.tsx`):
116
-
117
- ```tsx
118
- <head>
119
- {/* ... */}
120
- <script type="module">
121
- import * as Turbo from "https://esm.sh/@hotwired/turbo@8.0.0";
122
- Turbo.start();
123
- </script>
124
- </head>
125
- ```
126
-
127
- This approach leverages Import Maps and browser-native modules, keeping your bundle size small and your architecture transparent.
128
-
129
- ## Documentation
130
-
131
- * **English**:
132
- * [**Wisdom for Salvia**](docs/en/DESIGN.md): Deep dive into the architecture, directory structure, and "True HTML First" philosophy.
133
- * [**Architecture**](docs/en/ARCHITECTURE.md): Internal design of the gem.
134
- * **Japanese (日本語)**:
135
- * [**Salviaの知恵**](docs/ja/DESIGN.md): アーキテクチャ、ディレクトリ構造、「真のHTMLファースト」哲学についての詳細。
136
- * [**アーキテクチャ**](docs/ja/ARCHITECTURE.md): Gemの内部設計。
137
-
138
- ## Framework Support
139
-
140
- Salvia is primarily designed for **Ruby on Rails** to pave the way for the **Sage** framework.
141
-
142
- * **Ruby on Rails**: First-class support.
143
-
144
- ## Requirements
145
-
146
- * Ruby 3.1+
147
- * Rails 7.0+ (Recommended)
148
- * Deno (for JIT compilation and tooling)
149
-
150
- ## License
151
-
152
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).