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,167 @@
1
+ import * as esbuild from "https://deno.land/x/esbuild@v0.24.2/mod.js";
2
+ import { denoPlugins } from "jsr:@luca/esbuild-deno-loader@0.11";
3
+
4
+ // Use port 0 to let OS assign a free port
5
+ const PORT = 0;
6
+
7
+ console.log(`[Deno Init] 🚀 Salvia Sidecar starting...`);
8
+
9
+ const handler = async (request: Request): Promise<Response> => {
10
+ if (request.method !== "POST") {
11
+ return new Response("Method Not Allowed", { status: 405 });
12
+ }
13
+
14
+ try {
15
+ const body = await request.json();
16
+ const { command, params } = body;
17
+
18
+ if (command === "bundle") {
19
+ const { entryPoint, externals, format, globalName, configPath } = params;
20
+
21
+ // If format is IIFE, we need to handle externals by mapping them to globals
22
+ // But esbuild doesn't support this out of the box for IIFE with externals.
23
+ // We can use a plugin to rewrite imports to globals if they are in externals list.
24
+
25
+ // 4. Global Externals (for IIFE format)
26
+ const globalExternalsPlugin = {
27
+ name: "global-externals",
28
+ setup(build: any) {
29
+ // Preact
30
+ build.onResolve({ filter: /^preact$/ }, (args: any) => {
31
+ return { path: args.path, namespace: "global-external" };
32
+ });
33
+ // Hooks
34
+ build.onResolve({ filter: /^preact\/hooks$/ }, (args: any) => {
35
+ return { path: args.path, namespace: "global-external" };
36
+ });
37
+ // Signals
38
+ build.onResolve({ filter: /^@preact\/signals$/ }, (args: any) => {
39
+ return { path: args.path, namespace: "global-external" };
40
+ });
41
+ // JSX Runtime
42
+ build.onResolve({ filter: /^preact\/jsx-runtime$/ }, (args: any) => {
43
+ return { path: args.path, namespace: "global-external" };
44
+ });
45
+
46
+ build.onLoad({ filter: /.*/, namespace: "global-external" }, (args: any) => {
47
+ if (args.path === "preact") return { contents: "module.exports = globalThis.Preact;", loader: "js" };
48
+ if (args.path === "preact/hooks") return { contents: "module.exports = globalThis.PreactHooks;", loader: "js" };
49
+ if (args.path === "@preact/signals") return { contents: "module.exports = globalThis.PreactSignals;", loader: "js" };
50
+ if (args.path === "preact/jsx-runtime") return { contents: "module.exports = globalThis.PreactJsxRuntime;", loader: "js" };
51
+ return null;
52
+ });
53
+ },
54
+ };
55
+
56
+ const externalizePlugin = {
57
+ name: 'externalize-deps',
58
+ setup(build: any) {
59
+ build.onResolve({ filter: /.*/ }, (args: any) => {
60
+ if (externals && externals.includes(args.path)) {
61
+ return { path: args.path, external: true };
62
+ }
63
+ });
64
+ },
65
+ };
66
+
67
+ const isIIFE = format === "iife";
68
+
69
+ const plugins = [
70
+ ...denoPlugins({ configPath: configPath || `${Deno.cwd()}/salvia/deno.json` })
71
+ ];
72
+
73
+ if (isIIFE && !entryPoint.endsWith("vendor_setup.ts")) {
74
+ // IIFEの場合は、globalExternalsPlugin を使う
75
+ // ただし、denoPlugins よりも前に配置して、framework などの解決を横取りする
76
+ plugins.unshift(globalExternalsPlugin);
77
+ } else {
78
+ plugins.unshift(externalizePlugin);
79
+ }
80
+
81
+ // JIT Bundle for a specific entry point
82
+ const result = await esbuild.build({
83
+ entryPoints: [entryPoint],
84
+ bundle: true,
85
+ format: format || "esm",
86
+ globalName: globalName || undefined, // Exports will be in SalviaComponent.default
87
+ platform: "neutral",
88
+ plugins: plugins,
89
+ external: [], // We handle externals manually with plugins
90
+ write: false, // Return in memory
91
+ // 3. JSX Runtime (Automatic)
92
+ // deno.json の "preact/jsx-runtime" エイリアスを使用
93
+ jsx: "automatic",
94
+ jsxImportSource: "preact",
95
+ minify: false, // Keep it readable for debugging
96
+ });
97
+
98
+ const code = result.outputFiles[0].text;
99
+ // Deno.writeTextFileSync("debug_bundle.js", code);
100
+
101
+ return new Response(JSON.stringify({ code }), {
102
+ headers: { "Content-Type": "application/json" },
103
+ });
104
+ }
105
+
106
+ if (command === "check") {
107
+ const { entryPoint, configPath } = params;
108
+ const cmd = new Deno.Command("deno", {
109
+ args: ["check", "--config", configPath || "deno.json", entryPoint],
110
+ stdout: "piped",
111
+ stderr: "piped",
112
+ cwd: Deno.cwd(),
113
+ });
114
+
115
+ const output = await cmd.output();
116
+ const success = output.code === 0;
117
+ const message = new TextDecoder().decode(output.stderr);
118
+
119
+ return new Response(JSON.stringify({ success, message }), {
120
+ headers: { "Content-Type": "application/json" },
121
+ });
122
+ }
123
+
124
+ if (command === "fmt") {
125
+ const { entryPoint, configPath } = params;
126
+ const cmd = new Deno.Command("deno", {
127
+ args: ["fmt", "--config", configPath || "deno.json", entryPoint],
128
+ stdout: "piped",
129
+ stderr: "piped",
130
+ cwd: Deno.cwd(),
131
+ });
132
+
133
+ const output = await cmd.output();
134
+ const success = output.code === 0;
135
+ const message = new TextDecoder().decode(output.stderr);
136
+
137
+ return new Response(JSON.stringify({ success, message }), {
138
+ headers: { "Content-Type": "application/json" },
139
+ });
140
+ }
141
+
142
+ if (command === "ping") {
143
+ return new Response(JSON.stringify({ status: "pong" }));
144
+ }
145
+
146
+ return new Response("Unknown command", { status: 400 });
147
+
148
+ } catch (e) {
149
+ const err = e as Error;
150
+ console.error("Sidecar Error:", err);
151
+ return new Response(JSON.stringify({ error: err.message }), { status: 500 });
152
+ }
153
+ };
154
+
155
+ const server = Deno.serve({ port: PORT }, handler);
156
+ // Output the assigned port so Ruby can read it
157
+ console.log(`[Deno Init] Listening on http://localhost:${server.addr.port}/`);
158
+
159
+ // Handle cleanup on exit
160
+ const cleanup = () => {
161
+ console.log("🛑 Stopping Sidecar...");
162
+ esbuild.stop();
163
+ Deno.exit();
164
+ };
165
+
166
+ Deno.addSignalListener("SIGINT", cleanup);
167
+ Deno.addSignalListener("SIGTERM", cleanup);
@@ -0,0 +1,25 @@
1
+ import { h, Fragment } from "preact";
2
+ import * as preact from "preact";
3
+ import * as hooks from "preact/hooks";
4
+ import * as signals from "@preact/signals";
5
+ import * as jsxRuntime from "preact/jsx-runtime";
6
+ import { renderToString } from "preact-render-to-string";
7
+
8
+ // Expose to global scope for JIT bundles (IIFE)
9
+ (globalThis as any).Preact = preact;
10
+ (globalThis as any).PreactHooks = hooks;
11
+ (globalThis as any).PreactSignals = signals;
12
+ (globalThis as any).PreactJsxRuntime = jsxRuntime;
13
+ (globalThis as any).renderToString = renderToString;
14
+ (globalThis as any).h = h;
15
+ (globalThis as any).Fragment = Fragment;
16
+
17
+ // Simple require shim for JIT bundles
18
+ (globalThis as any).require = function(moduleName: string) {
19
+ if (moduleName === "preact") return preact;
20
+ if (moduleName === "preact/hooks") return hooks;
21
+ if (moduleName === "@preact/signals") return signals;
22
+ if (moduleName === "preact/jsx-runtime") return jsxRuntime;
23
+ if (moduleName === "preact-render-to-string") return { default: renderToString, renderToString };
24
+ throw new Error("Module not found: " + moduleName);
25
+ };
data/exe/salvia ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "salvia"
5
+ require "salvia/cli"
6
+
7
+ Salvia::CLI.start(ARGV)
8
+
data/lib/salvia/cli.rb ADDED
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "fileutils"
5
+
6
+ module Salvia
7
+ class CLI < Thor
8
+ include Thor::Actions
9
+
10
+ def self.source_root
11
+ File.expand_path("../../..", __FILE__)
12
+ end
13
+
14
+ desc "install", "Install Salvia SSR files into your application"
15
+ def install
16
+ say "🌿 Salvia Installer", :bold
17
+ say "===================", :bold
18
+ say ""
19
+
20
+ # 1. Backend Framework
21
+ backend = if File.exist?("bin/rails") || File.exist?("config/application.rb")
22
+ "rails"
23
+ else
24
+ "other"
25
+ end
26
+
27
+ # 2. Tailwind CSS
28
+ install_tailwind = yes?("1. Do you want to install Tailwind CSS (via tailwindcss-ruby)? (y/N)", :yellow)
29
+
30
+ say ""
31
+ say "🚀 Installing Salvia (Preact + Signals) for #{backend}...", :green
32
+ say ""
33
+
34
+ # Create directories
35
+ empty_directory "salvia/app/islands"
36
+ empty_directory "salvia/app/pages"
37
+ empty_directory "salvia/app/components"
38
+ empty_directory "public/assets/javascripts"
39
+ empty_directory "public/assets/islands"
40
+ empty_directory "salvia"
41
+
42
+ # Copy files
43
+ # Note: source_root is 'salvia/', so paths are relative to that
44
+ copy_file "assets/scripts/deno.json", "salvia/deno.json"
45
+ copy_file "assets/components/Island.tsx", "salvia/app/components/Island.tsx"
46
+ copy_file "assets/islands/Counter.tsx", "salvia/app/islands/Counter.tsx"
47
+ copy_file "assets/pages/Home.tsx", "salvia/app/pages/Home.tsx"
48
+
49
+ create_file "salvia/.gitignore", "/server/\n"
50
+
51
+ # Backend Setup
52
+ case backend
53
+ when "rails"
54
+ create_file "config/initializers/salvia.rb" do
55
+ <<~RUBY
56
+ Salvia.configure do |config|
57
+ config.islands_dir = Rails.root.join("salvia/app/islands")
58
+ config.build_dir = Rails.root.join("public/assets")
59
+ config.ssr_bundle_path = Rails.root.join("salvia/server/ssr_bundle.js")
60
+ end
61
+
62
+ # Initialize SSR Engine
63
+ Salvia::SSR.configure(
64
+ bundle_path: Salvia.config.ssr_bundle_path,
65
+ development: Rails.env.development?
66
+ )
67
+ RUBY
68
+ end
69
+ say " - Created config/initializers/salvia.rb"
70
+ end
71
+
72
+ # Tailwind CSS Setup
73
+ if install_tailwind
74
+ # Add tailwindcss-ruby to Gemfile if present
75
+ if File.exist?("Gemfile")
76
+ unless File.read("Gemfile").include?("tailwindcss-ruby")
77
+ append_to_file "Gemfile", "\ngem 'tailwindcss-ruby'\n"
78
+ say " - Added 'tailwindcss-ruby' to Gemfile"
79
+ end
80
+ end
81
+
82
+ empty_directory "app/assets/stylesheets"
83
+ create_file "app/assets/stylesheets/application.tailwind.css" do
84
+ <<~CSS
85
+ @import "tailwindcss";
86
+
87
+ @source "../../views/**/*.erb";
88
+ @source "../../islands/**/*.{js,jsx,tsx}";
89
+ @source "../../../public/assets/javascripts/**/*.js";
90
+
91
+ @theme {
92
+ --color-salvia-500: #6A5ACD;
93
+ --color-salvia-600: #5a4ab8;
94
+ }
95
+ CSS
96
+ end
97
+
98
+ say " - app/assets/stylesheets/ : Tailwind CSS entry point created (v4)"
99
+ end
100
+
101
+ say ""
102
+ say "✅ Salvia SSR installed!", :green
103
+ say " - salvia/app/islands/ : Put your interactive Island components here"
104
+ say " - salvia/app/pages/ : Put your static Server Components here (JS-free)"
105
+ say " - salvia/app/components/ : Put your shared/static components here"
106
+ say ""
107
+ say "Next steps:", :yellow
108
+ say " 1. Install Deno: https://deno.land"
109
+ if install_tailwind
110
+ say " 2. Run 'bundle install' to install Tailwind"
111
+ say " 3. Run build: salvia build"
112
+ say " 4. Watch assets: bundle exec foreman start -f Procfile.dev (recommended)"
113
+ else
114
+ say " 2. Run build: salvia build"
115
+ end
116
+ end
117
+
118
+ desc "build", "Build Island components for SSR"
119
+ method_option :verbose, aliases: "-v", type: :boolean, default: false, desc: "Verbose output"
120
+ def build
121
+ check_deno_installed!
122
+
123
+ say "🏝️ Building Island components...", :green
124
+
125
+ # Use internal build script
126
+ build_script = File.expand_path("../../../assets/scripts/build.ts", __FILE__)
127
+
128
+ # Use user's deno.json if available, otherwise fallback to internal
129
+ user_config = File.expand_path("salvia/deno.json")
130
+ config_path = if File.exist?(user_config)
131
+ user_config
132
+ else
133
+ File.expand_path("../../../assets/scripts/deno.json", __FILE__)
134
+ end
135
+
136
+ cmd = "deno run --allow-all --config #{config_path} #{build_script}"
137
+ cmd += " --verbose" if options[:verbose]
138
+
139
+ success = system(cmd)
140
+
141
+ if success
142
+ say "✅ SSR build completed!", :green
143
+ else
144
+ say "❌ SSR build failed", :red
145
+ exit 1
146
+ end
147
+ end
148
+
149
+ desc "watch", "Watch and rebuild Island components"
150
+ method_option :verbose, aliases: "-v", type: :boolean, default: false, desc: "Verbose output"
151
+ def watch
152
+ check_deno_installed!
153
+
154
+ say "👀 Watching Island components...", :green
155
+
156
+ # Use internal build script
157
+ build_script = File.expand_path("../../../assets/scripts/build.ts", __FILE__)
158
+
159
+ # Use user's deno.json if available, otherwise fallback to internal
160
+ user_config = File.expand_path("salvia/deno.json")
161
+ config_path = if File.exist?(user_config)
162
+ user_config
163
+ else
164
+ File.expand_path("../../../assets/scripts/deno.json", __FILE__)
165
+ end
166
+
167
+ cmd = "deno run --allow-all --config #{config_path} #{build_script} --watch"
168
+ cmd += " --verbose" if options[:verbose]
169
+
170
+ exec cmd
171
+ end
172
+
173
+ desc "version", "Display Salvia version"
174
+ def version
175
+ require "salvia/version"
176
+ say "Salvia #{Salvia::VERSION}"
177
+ end
178
+
179
+ private
180
+
181
+ def check_deno_installed!
182
+ unless system("which deno > /dev/null 2>&1")
183
+ say "❌ Deno is not installed.", :red
184
+ say "Please install Deno: https://deno.land", :yellow
185
+ exit 1
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,23 @@
1
+ module Salvia
2
+ class Compiler
3
+ module Adapters
4
+ class DenoSidecar
5
+ def bundle(entry_point, **options)
6
+ Salvia::Server::Sidecar.instance.bundle(entry_point, **options)
7
+ end
8
+
9
+ def check(entry_point, **options)
10
+ Salvia::Server::Sidecar.instance.check(entry_point, **options)
11
+ end
12
+
13
+ def fmt(entry_point, **options)
14
+ Salvia::Server::Sidecar.instance.fmt(entry_point, **options)
15
+ end
16
+
17
+ def shutdown
18
+ Salvia::Server::Sidecar.instance.stop
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,29 @@
1
+ module Salvia
2
+ class Compiler
3
+ class Error < Salvia::Error; end
4
+
5
+ class << self
6
+ attr_writer :adapter
7
+
8
+ def adapter
9
+ @adapter ||= Salvia::Compiler::Adapters::DenoSidecar.new
10
+ end
11
+
12
+ def bundle(entry_point, **options)
13
+ adapter.bundle(entry_point, **options)
14
+ end
15
+
16
+ def check(entry_point)
17
+ adapter.check(entry_point)
18
+ end
19
+
20
+ def fmt(entry_point)
21
+ adapter.fmt(entry_point)
22
+ end
23
+
24
+ def shutdown
25
+ adapter.shutdown if adapter.respond_to?(:shutdown)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Salvia
4
+ module Core
5
+ class Configuration
6
+ attr_accessor :ssr_bundle_path, :island_inspector, :islands_dir, :build_dir, :deno_config_path, :root
7
+
8
+ def initialize
9
+ @root = Dir.pwd
10
+ @ssr_bundle_path = "salvia/server/ssr_bundle.js"
11
+ @islands_dir = "salvia/app/islands"
12
+ @build_dir = "public/assets"
13
+
14
+ user_deno_json = File.join(@root, "salvia/deno.json")
15
+ if File.exist?(user_deno_json)
16
+ @deno_config_path = user_deno_json
17
+ else
18
+ @deno_config_path = File.expand_path("../../../assets/scripts/deno.json", __dir__)
19
+ end
20
+
21
+ @island_inspector = nil
22
+ end
23
+
24
+ def island_inspector?
25
+ return @island_inspector unless @island_inspector.nil?
26
+ Salvia.development?
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Salvia
4
+ module Core
5
+ class Error < StandardError; end
6
+ end
7
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Salvia
6
+ module Core
7
+ class ImportMap
8
+ def self.load
9
+ new.load
10
+ end
11
+
12
+ def load
13
+ path = find_deno_json
14
+ return {} unless path
15
+
16
+ begin
17
+ content = File.read(path)
18
+ json = JSON.parse(content)
19
+ json["imports"] || {}
20
+ rescue JSON::ParserError
21
+ {}
22
+ end
23
+ end
24
+
25
+ def keys
26
+ load.keys
27
+ end
28
+
29
+ private
30
+
31
+ def find_deno_json
32
+ Salvia.config.deno_config_path
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Salvia
4
+ module Core
5
+ class PathResolver
6
+ def self.resolve(name)
7
+ # Check flat structure
8
+ path = File.join(Salvia.root, "#{name}.tsx")
9
+ return path if File.exist?(path)
10
+
11
+ roots = [
12
+ "salvia/app/pages",
13
+ "salvia/app/islands",
14
+ "salvia/app/components"
15
+ ]
16
+
17
+ roots.each do |root|
18
+ path = File.join(Salvia.root, root, "#{name}.tsx")
19
+ return path if File.exist?(path)
20
+
21
+ path = File.join(Salvia.root, root, "#{name}.jsx")
22
+ return path if File.exist?(path)
23
+
24
+ path = File.join(Salvia.root, root, "#{name}.js")
25
+ return path if File.exist?(path)
26
+ end
27
+
28
+ if name.include?("/")
29
+ path = File.join(Salvia.root, "salvia/app", "#{name}.tsx")
30
+ return path if File.exist?(path)
31
+
32
+ path = File.join(Salvia.root, "salvia/app", "#{name}.jsx")
33
+ return path if File.exist?(path)
34
+
35
+ path = File.join(Salvia.root, "salvia/app", "#{name}.js")
36
+ return path if File.exist?(path)
37
+ end
38
+
39
+ nil
40
+ end
41
+ end
42
+ end
43
+ end