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,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Salvia
4
+ module Helpers
5
+ module Component
6
+ # View Component をレンダリングする
7
+ #
8
+ # @param name [String, Symbol] コンポーネント名 (例: "user_card")
9
+ # @param kwargs [Hash] コンポーネントに渡す引数
10
+ # @param block [Proc] コンテンツブロック
11
+ # @return [String] レンダリング結果
12
+ def component(name, **kwargs, &block)
13
+ # "user_card" -> "UserCardComponent"
14
+ class_name = "#{name.to_s.camelize}Component"
15
+
16
+ begin
17
+ klass = Object.const_get(class_name)
18
+ rescue NameError
19
+ raise NameError, "Component class not found: #{class_name} (expected app/components/#{name}_component.rb)"
20
+ end
21
+
22
+ unless klass < Salvia::Component
23
+ raise ArgumentError, "#{class_name} must inherit from Salvia::Component"
24
+ end
25
+
26
+ instance = klass.new(**kwargs)
27
+ instance.render_in(self, &block)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Salvia
4
+ module Helpers
5
+ # CSRF 保護用ヘルパー
6
+ module CSRF
7
+ # CSRF トークンを取得
8
+ #
9
+ # @return [String] CSRF トークン
10
+ #
11
+ # @example
12
+ # <input type="hidden" name="authenticity_token" value="<%= csrf_token %>">
13
+ #
14
+ def csrf_token
15
+ Salvia::CSRF.token(session)
16
+ end
17
+
18
+ # CSRF meta タグを生成
19
+ #
20
+ # @return [String] meta タグ HTML
21
+ #
22
+ # @example
23
+ # <head>
24
+ # <%= csrf_meta_tag %>
25
+ # </head>
26
+ #
27
+ def csrf_meta_tag
28
+ %(<meta name="csrf-token" content="#{csrf_token}">)
29
+ end
30
+ alias csrf_meta_tags csrf_meta_tag
31
+
32
+ # CSRF hidden input を生成
33
+ #
34
+ # @return [String] hidden input HTML
35
+ #
36
+ # @example
37
+ # <form method="post">
38
+ # <%= csrf_field %>
39
+ # ...
40
+ # </form>
41
+ #
42
+ def csrf_field
43
+ %(<input type="hidden" name="authenticity_token" value="#{csrf_token}">)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Salvia
4
+ module Helpers
5
+ # Island Inspector ヘルパー
6
+ #
7
+ # 開発モードで Island コンポーネントをデバッグするための
8
+ # ツールを提供します。
9
+ module Inspector
10
+ # Island Inspector のスクリプトとスタイルを読み込むタグを生成
11
+ #
12
+ # @return [String] script タグと style タグ
13
+ def island_inspector_tags
14
+ return "" unless Salvia.development? && Salvia.config.island_inspector?
15
+
16
+ css = inspector_css
17
+ js = inspector_js
18
+
19
+ "<style>#{css}</style>\n<script>#{js}</script>"
20
+ end
21
+
22
+ private
23
+
24
+ def inspector_css
25
+ <<~CSS
26
+ .salvia-island-highlight {
27
+ outline: 2px dashed #6B46C1 !important;
28
+ outline-offset: 2px;
29
+ position: relative;
30
+ }
31
+
32
+ .salvia-island-highlight::after {
33
+ content: attr(data-island);
34
+ position: absolute;
35
+ top: -20px;
36
+ left: 0;
37
+ background: #6B46C1;
38
+ color: white;
39
+ font-size: 10px;
40
+ padding: 2px 6px;
41
+ border-radius: 3px;
42
+ font-family: ui-monospace, monospace;
43
+ z-index: 10000;
44
+ pointer-events: none;
45
+ }
46
+
47
+ .salvia-inspector-panel {
48
+ position: fixed;
49
+ bottom: 20px;
50
+ right: 20px;
51
+ width: 320px;
52
+ max-height: 400px;
53
+ background: #1e1e1e;
54
+ color: #d4d4d4;
55
+ border-radius: 8px;
56
+ box-shadow: 0 4px 20px rgba(0,0,0,0.3);
57
+ font-family: ui-monospace, monospace;
58
+ font-size: 12px;
59
+ z-index: 10001;
60
+ overflow: hidden;
61
+ }
62
+
63
+ .salvia-inspector-header {
64
+ background: #6B46C1;
65
+ color: white;
66
+ padding: 8px 12px;
67
+ font-weight: bold;
68
+ display: flex;
69
+ justify-content: space-between;
70
+ align-items: center;
71
+ }
72
+
73
+ .salvia-inspector-close {
74
+ background: none;
75
+ border: none;
76
+ color: white;
77
+ cursor: pointer;
78
+ font-size: 16px;
79
+ padding: 0 4px;
80
+ }
81
+
82
+ .salvia-inspector-content {
83
+ padding: 12px;
84
+ max-height: 340px;
85
+ overflow-y: auto;
86
+ }
87
+
88
+ .salvia-inspector-section {
89
+ margin-bottom: 12px;
90
+ }
91
+
92
+ .salvia-inspector-label {
93
+ color: #9cdcfe;
94
+ margin-bottom: 4px;
95
+ }
96
+
97
+ .salvia-inspector-value {
98
+ background: #2d2d2d;
99
+ padding: 6px 8px;
100
+ border-radius: 4px;
101
+ word-break: break-all;
102
+ }
103
+
104
+ .salvia-inspector-json {
105
+ white-space: pre-wrap;
106
+ color: #ce9178;
107
+ }
108
+ CSS
109
+ end
110
+
111
+ def inspector_js
112
+ <<~JS
113
+ (function() {
114
+ let currentPanel = null;
115
+
116
+ // Alt + Click でパネルを表示
117
+ document.addEventListener('click', function(e) {
118
+ if (!e.altKey) return;
119
+
120
+ const island = e.target.closest('[data-island]');
121
+ if (!island) return;
122
+
123
+ e.preventDefault();
124
+ e.stopPropagation();
125
+
126
+ showInspectorPanel(island);
127
+ });
128
+
129
+ // ホバー時にハイライト
130
+ document.addEventListener('mouseover', function(e) {
131
+ const island = e.target.closest('[data-island]');
132
+ if (island) {
133
+ island.classList.add('salvia-island-highlight');
134
+ }
135
+ });
136
+
137
+ document.addEventListener('mouseout', function(e) {
138
+ const island = e.target.closest('[data-island]');
139
+ if (island) {
140
+ island.classList.remove('salvia-island-highlight');
141
+ }
142
+ });
143
+
144
+ function showInspectorPanel(island) {
145
+ if (currentPanel) {
146
+ currentPanel.remove();
147
+ }
148
+
149
+ const name = island.dataset.island;
150
+ const props = JSON.parse(island.dataset.props || '{}');
151
+ const ssr = island.dataset.ssr === 'true';
152
+ const hydrated = island.dataset.hydrated === 'true';
153
+
154
+ const panel = document.createElement('div');
155
+ panel.className = 'salvia-inspector-panel';
156
+ panel.innerHTML = `
157
+ <div class="salvia-inspector-header">
158
+ <span>🏝️ \${name}</span>
159
+ <button class="salvia-inspector-close">×</button>
160
+ </div>
161
+ <div class="salvia-inspector-content">
162
+ <div class="salvia-inspector-section">
163
+ <div class="salvia-inspector-label">SSR</div>
164
+ <div class="salvia-inspector-value">\${ssr ? '✅ Yes' : '❌ No'}</div>
165
+ </div>
166
+ <div class="salvia-inspector-section">
167
+ <div class="salvia-inspector-label">Hydrated</div>
168
+ <div class="salvia-inspector-value">\${hydrated ? '✅ Yes' : '⏳ Pending'}</div>
169
+ </div>
170
+ <div class="salvia-inspector-section">
171
+ <div class="salvia-inspector-label">Props</div>
172
+ <div class="salvia-inspector-value salvia-inspector-json">\${JSON.stringify(props, null, 2)}</div>
173
+ </div>
174
+ </div>
175
+ `;
176
+
177
+ panel.querySelector('.salvia-inspector-close').addEventListener('click', function() {
178
+ panel.remove();
179
+ currentPanel = null;
180
+ });
181
+
182
+ document.body.appendChild(panel);
183
+ currentPanel = panel;
184
+ }
185
+
186
+ console.log('🔍 Salvia Island Inspector: Alt+Click on any island to inspect');
187
+ })();
188
+ JS
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,143 @@
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, "vendor/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
+ end
34
+
35
+ # Island コンポーネントをレンダリングする
36
+ #
37
+ # SSR が有効な場合はサーバーサイドで HTML を生成し、
38
+ # クライアントサイドでハイドレーションを行います。
39
+ #
40
+ # @param name [String] コンポーネント名 (例: "Counter")
41
+ # @param props [Hash] コンポーネントに渡すプロパティ
42
+ # @param options [Hash] オプション
43
+ # @option options [String] :id 要素のID
44
+ # @option options [String] :tag ラッパータグ (デフォルト: div)
45
+ # @option options [Boolean] :ssr SSR を有効にするか (デフォルト: auto)
46
+ # @option options [Boolean] :hydrate クライアントサイドでハイドレーションするか (デフォルト: true)
47
+ # @return [String] レンダリングされた HTML
48
+ #
49
+ # @example 基本的な使用法
50
+ # <%= island "Counter", count: 5 %>
51
+ #
52
+ # @example SSR を明示的に無効化
53
+ # <%= island "HeavyChart", data: @data, ssr: false %>
54
+ #
55
+ # @example ハイドレーションを無効化 (静的 HTML のみ)
56
+ # <%= island "StaticCard", title: "Hello", hydrate: false %>
57
+ #
58
+ def island(name, props = {}, options = {})
59
+ tag_name = options.delete(:tag) || :div
60
+ hydrate = options.fetch(:hydrate, true)
61
+
62
+ # SSR 有効/無効の判定
63
+ # 1. options[:ssr] が明示的に指定されていればそれを使う
64
+ # 2. マニフェストで "client only" ならば SSR 無効
65
+ # 3. デフォルトは SSR 有効
66
+ ssr_enabled = if options.key?(:ssr)
67
+ options[:ssr]
68
+ else
69
+ !Island.client_only?(name)
70
+ end
71
+
72
+ # 開発モードかどうか
73
+ development = defined?(Salvia.env) ? Salvia.env == "development" : true
74
+
75
+ # SSR でコンテンツを生成
76
+ inner_html = ""
77
+ begin
78
+ if ssr_enabled && defined?(Salvia::SSR) && Salvia::SSR.respond_to?(:configured?) && Salvia::SSR.configured?
79
+ inner_html = Salvia::SSR.render(name, props)
80
+ end
81
+ rescue => e
82
+ # SSR 失敗時はエラーをログに出力し、CSR にフォールバック
83
+ if development
84
+ inner_html = ssr_error_inline(name, e.message)
85
+ end
86
+ end
87
+
88
+ # データ属性を構築
89
+ data_attrs = {}
90
+
91
+ if hydrate
92
+ data_attrs[:island] = name
93
+ data_attrs[:props] = props.to_json
94
+ end
95
+
96
+ # 開発モードではデバッグ用の属性を追加
97
+ if development
98
+ data_attrs[:salvia_debug] = true
99
+ data_attrs[:salvia_component] = name
100
+ end
101
+
102
+ # HTML オプションを構築
103
+ html_options = options.dup
104
+ html_options.delete(:ssr)
105
+ html_options.delete(:hydrate)
106
+ html_options[:data] = (html_options[:data] || {}).merge(data_attrs)
107
+
108
+ # 開発モードではインスペクター用のクラスを追加
109
+ if development
110
+ html_options[:class] = [html_options[:class], "salvia-island"].compact.join(" ")
111
+ end
112
+
113
+ tag(tag_name, html_options) { inner_html }
114
+ end
115
+
116
+ private
117
+
118
+ # インラインエラー表示 (開発モード用、軽量版)
119
+ def ssr_error_inline(name, message)
120
+ <<~HTML
121
+ <div style="
122
+ background: #fee;
123
+ border: 1px solid #fcc;
124
+ border-radius: 4px;
125
+ padding: 8px 12px;
126
+ font-size: 12px;
127
+ color: #900;
128
+ ">
129
+ <strong>⚠️ SSR Error:</strong> #{escape_html(name)} - #{escape_html(message)}
130
+ </div>
131
+ HTML
132
+ end
133
+
134
+ def escape_html(str)
135
+ str.to_s
136
+ .gsub("&", "&amp;")
137
+ .gsub("<", "&lt;")
138
+ .gsub(">", "&gt;")
139
+ .gsub('"', "&quot;")
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Salvia
4
+ module Helpers
5
+ module Tag
6
+ # HTMLタグを生成する
7
+ #
8
+ # @param name [String, Symbol] タグ名
9
+ # @param options [Hash] 属性
10
+ # @param block [Proc] コンテンツブロック
11
+ # @return [String]
12
+ def tag(name, options = {}, &block)
13
+ html_options = options.dup
14
+ data = html_options.delete(:data)
15
+
16
+ attributes = html_options.map { |k, v| "#{k}=\"#{v.to_s.gsub('"', '&quot;')}\"" }
17
+
18
+ if data
19
+ data.each do |k, v|
20
+ key = k.to_s.tr("_", "-") # dasherize
21
+ attributes << "data-#{key}=\"#{v.to_s.gsub('"', '&quot;')}\""
22
+ end
23
+ end
24
+
25
+ attributes = attributes.join(" ")
26
+ attributes = " " + attributes unless attributes.empty?
27
+
28
+ if block_given?
29
+ content = block.call
30
+ "<#{name}#{attributes}>#{content}</#{name}>"
31
+ else
32
+ "<#{name}#{attributes}>"
33
+ end
34
+ end
35
+
36
+ # リンクタグを生成する
37
+ #
38
+ # @param name [String] リンクテキスト
39
+ # @param url [String] URL
40
+ # @param options [Hash] 属性
41
+ # @return [String]
42
+ def link_to(name, url, options = {})
43
+ tag(:a, options.merge(href: url)) { name }
44
+ end
45
+
46
+ # フォーム開始タグを生成する
47
+ #
48
+ # @param url [String] アクションURL
49
+ # @param options [Hash] オプション
50
+ # @option options [Symbol] :method HTTPメソッド (デフォルト: :post)
51
+ # @return [String]
52
+ def form_tag(url, options = {})
53
+ html_options = options.dup
54
+ method = html_options.delete(:method) || :post
55
+
56
+ # method が get/post 以外の場合、_method パラメータを使用
57
+ real_method = method.to_s.downcase
58
+ form_method = %w[get post].include?(real_method) ? real_method : "post"
59
+
60
+ html_options[:action] = url
61
+ html_options[:method] = form_method
62
+
63
+ # 属性を文字列化
64
+ attributes = html_options.map { |k, v| "#{k}=\"#{v}\"" }.join(" ")
65
+ attributes = " " + attributes unless attributes.empty?
66
+
67
+ html = ["<form#{attributes}>"]
68
+
69
+ # CSRF トークン (GET 以外)
70
+ if real_method != "get" && respond_to?(:csrf_token) && csrf_token
71
+ html << tag(:input, type: "hidden", name: "authenticity_token", value: csrf_token)
72
+ end
73
+
74
+ # Method Override
75
+ if real_method != form_method
76
+ html << tag(:input, type: "hidden", name: "_method", value: real_method)
77
+ end
78
+
79
+ html.join("\n")
80
+ end
81
+
82
+ # フォーム終了タグを生成する
83
+ #
84
+ # @return [String]
85
+ def form_close
86
+ "</form>"
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Salvia
4
+ module Helpers
5
+ include Tag
6
+ include Component
7
+ include Island
8
+ include Inspector
9
+ include CSRF
10
+ end
11
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Salvia
4
+ module Plugins
5
+ # プラグイン管理モジュール
6
+ #
7
+ # Salvia のオプショナル機能をプラグインとして管理します。
8
+ #
9
+ # @example プラグインの有効化
10
+ # Salvia.configure do |config|
11
+ # config.plugins << :htmx
12
+ # end
13
+ #
14
+ module Base
15
+ class << self
16
+ # 登録済みプラグイン
17
+ def registry
18
+ @registry ||= {}
19
+ end
20
+
21
+ # プラグインを登録
22
+ def register(name, mod)
23
+ registry[name.to_sym] = mod
24
+ end
25
+
26
+ # プラグインを取得
27
+ def get(name)
28
+ registry[name.to_sym]
29
+ end
30
+
31
+ # プラグインが登録されているか確認
32
+ def registered?(name)
33
+ registry.key?(name.to_sym)
34
+ end
35
+
36
+ # 有効なプラグイン一覧
37
+ def enabled
38
+ @enabled ||= []
39
+ end
40
+
41
+ # プラグインを有効化
42
+ def enable(name)
43
+ plugin = get(name)
44
+ raise ArgumentError, "Unknown plugin: #{name}" unless plugin
45
+
46
+ enabled << name.to_sym unless enabled.include?(name.to_sym)
47
+
48
+ # プラグインの初期化メソッドがあれば呼び出す
49
+ plugin.setup if plugin.respond_to?(:setup)
50
+ end
51
+
52
+ # プラグインが有効か確認
53
+ def enabled?(name)
54
+ enabled.include?(name.to_sym)
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end