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.
- checksums.yaml +7 -0
- data/LICENSE.txt +22 -0
- data/README.md +152 -0
- data/assets/components/Island.tsx +15 -0
- data/assets/islands/Counter.tsx +17 -0
- data/assets/javascripts/islands.js +55 -0
- data/assets/pages/Home.tsx +19 -0
- data/assets/scripts/build.ts +298 -0
- data/assets/scripts/deno.json +15 -0
- data/assets/scripts/deno.lock +56 -0
- data/assets/scripts/sidecar.ts +167 -0
- data/assets/scripts/vendor_setup.ts +25 -0
- data/exe/salvia +8 -0
- data/lib/salvia/cli.rb +189 -0
- data/lib/salvia/compiler/adapters/deno_sidecar.rb +23 -0
- data/lib/salvia/compiler.rb +29 -0
- data/lib/salvia/core/configuration.rb +30 -0
- data/lib/salvia/core/error.rb +7 -0
- data/lib/salvia/core/import_map.rb +36 -0
- data/lib/salvia/core/path_resolver.rb +43 -0
- data/lib/salvia/helpers/island.rb +251 -0
- data/lib/salvia/helpers/tag.rb +47 -0
- data/lib/salvia/helpers.rb +8 -0
- data/lib/salvia/railtie.rb +33 -0
- data/lib/salvia/server/dev_server.rb +83 -0
- data/lib/salvia/server/sidecar.rb +136 -0
- data/lib/salvia/server/sidecar.ts +167 -0
- data/lib/salvia/ssr/dom_mock.rb +46 -0
- data/lib/salvia/ssr/quickjs.rb +282 -0
- data/lib/salvia/ssr.rb +119 -0
- data/lib/salvia/version.rb +5 -0
- data/lib/salvia.rb +68 -0
- metadata +165 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d47620b2511642dc1d75545d11cfda1f52071059a320bf1b181735af2bca2f41
|
|
4
|
+
data.tar.gz: 0a7322cbf4477fa2bff9ad6664e658a056a24a9cec83b6943e17f1ffbaf00bac
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8b677e392d348bb21b8e6bcdb02e90ece13021e3c710fdfa394395174097ff81ee19fb3513ec09e7f752a392a79c1b63593747ec57d697cc4675c9730d755072
|
|
7
|
+
data.tar.gz: f55abe0fd41e6a76c5d39a728e56193ade7d986e32df1c49b05f9bfc0ba88b495f1b0483b686333dfbfca33c31b473f0953780472a81a09971f36fd3c7990dfd
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Hiroto Furugen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
data/README.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
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).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { h, ComponentType } from "preact";
|
|
2
|
+
|
|
3
|
+
interface IslandProps {
|
|
4
|
+
name: string;
|
|
5
|
+
component: ComponentType<any>;
|
|
6
|
+
[key: string]: any;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export default function Island({ name, component: Component, ...props }: IslandProps) {
|
|
10
|
+
return (
|
|
11
|
+
<div data-island={name} data-props={JSON.stringify(props)}>
|
|
12
|
+
<Component {...props} />
|
|
13
|
+
</div>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { h } from "preact";
|
|
2
|
+
import { useState } from "preact/hooks";
|
|
3
|
+
|
|
4
|
+
export default function Counter() {
|
|
5
|
+
const [count, setCount] = useState(0);
|
|
6
|
+
return (
|
|
7
|
+
<div class="p-4 border rounded-lg">
|
|
8
|
+
<p class="text-lg mb-2">Count: {count}</p>
|
|
9
|
+
<button
|
|
10
|
+
onClick={() => setCount(count + 1)}
|
|
11
|
+
class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
|
12
|
+
>
|
|
13
|
+
Increment
|
|
14
|
+
</button>
|
|
15
|
+
</div>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Salvia Islands - Client-side Hydration
|
|
2
|
+
// Framework-agnostic island loader
|
|
3
|
+
// Each island component must export a mount(element, props) function
|
|
4
|
+
|
|
5
|
+
import { h, hydrate, render } from "preact";
|
|
6
|
+
|
|
7
|
+
async function hydrateIsland(island) {
|
|
8
|
+
if (island.dataset.hydrated) return;
|
|
9
|
+
island.dataset.hydrated = "true";
|
|
10
|
+
|
|
11
|
+
const name = island.dataset.island;
|
|
12
|
+
const props = JSON.parse(island.dataset.props || '{}');
|
|
13
|
+
const hasSSR = island.innerHTML.trim().length > 0;
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
// Use the import map alias which handles dev/prod paths
|
|
17
|
+
const module = await import(`@/islands/${name}.js`);
|
|
18
|
+
|
|
19
|
+
if (typeof module.mount === 'function') {
|
|
20
|
+
module.mount(island, props, { hydrate: hasSSR });
|
|
21
|
+
console.log(`🏝️ Island ${hasSSR ? 'hydrated' : 'mounted'}: ${name}`);
|
|
22
|
+
} else if (module.default) {
|
|
23
|
+
// Auto-hydration for Preact components
|
|
24
|
+
if (hasSSR) {
|
|
25
|
+
hydrate(h(module.default, props), island);
|
|
26
|
+
} else {
|
|
27
|
+
render(h(module.default, props), island);
|
|
28
|
+
}
|
|
29
|
+
console.log(`🏝️ Island ${hasSSR ? 'hydrated' : 'mounted'} (auto): ${name}`);
|
|
30
|
+
} else {
|
|
31
|
+
console.error(`Island ${name} must export a mount() function or be a Preact component.`);
|
|
32
|
+
}
|
|
33
|
+
} catch (error) {
|
|
34
|
+
console.error(`Failed to load island: ${name}`, error);
|
|
35
|
+
island.removeAttribute('data-hydrated'); // Retry on next pass if failed?
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function hydrateAll() {
|
|
40
|
+
const islands = document.querySelectorAll('[data-island]');
|
|
41
|
+
for (const island of islands) {
|
|
42
|
+
hydrateIsland(island);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Initial hydration
|
|
47
|
+
document.addEventListener('DOMContentLoaded', hydrateAll);
|
|
48
|
+
|
|
49
|
+
// Support for Turbo Drive / Turbolinks
|
|
50
|
+
document.addEventListener('turbo:load', hydrateAll);
|
|
51
|
+
document.addEventListener('turbolinks:load', hydrateAll);
|
|
52
|
+
|
|
53
|
+
// Expose for manual hydration (e.g. HTMX)
|
|
54
|
+
globalThis.Salvia = globalThis.Salvia || {};
|
|
55
|
+
globalThis.Salvia.hydrateAll = hydrateAll;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { h } from "preact";
|
|
2
|
+
import Island from "../components/Island.tsx";
|
|
3
|
+
import Counter from "../islands/Counter.tsx";
|
|
4
|
+
|
|
5
|
+
export default function Home({ title }: { title: string }) {
|
|
6
|
+
return (
|
|
7
|
+
<html>
|
|
8
|
+
<head>
|
|
9
|
+
<title>{title}</title>
|
|
10
|
+
<script type="module" src="/assets/javascripts/islands.js"></script>
|
|
11
|
+
</head>
|
|
12
|
+
<body class="p-8">
|
|
13
|
+
<h1 class="text-3xl font-bold mb-4">{title}</h1>
|
|
14
|
+
<p class="mb-4">This is a Server Component (Page).</p>
|
|
15
|
+
<Island name="Counter" component={Counter} />
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
#!/usr/bin/env -S deno run --allow-all
|
|
2
|
+
// deno-lint-ignore-file
|
|
3
|
+
/**
|
|
4
|
+
* Salvia Build Script
|
|
5
|
+
*
|
|
6
|
+
* Build SSR Islands + Tailwind CSS
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* deno run --allow-all build_ssr.ts
|
|
10
|
+
* deno run --allow-all build_ssr.ts --watch
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as esbuild from "https://deno.land/x/esbuild@v0.24.2/mod.js";
|
|
14
|
+
import { denoPlugins } from "jsr:@luca/esbuild-deno-loader@0.11";
|
|
15
|
+
|
|
16
|
+
// Resolve deno.json relative to this script
|
|
17
|
+
let CONFIG_PATH = new URL("./deno.json", import.meta.url).pathname;
|
|
18
|
+
|
|
19
|
+
// Check for user config in project root
|
|
20
|
+
const USER_CONFIG_PATH = `${Deno.cwd()}/salvia/deno.json`;
|
|
21
|
+
try {
|
|
22
|
+
await Deno.stat(USER_CONFIG_PATH);
|
|
23
|
+
CONFIG_PATH = USER_CONFIG_PATH;
|
|
24
|
+
} catch {
|
|
25
|
+
// User config not found, use internal one
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// When running via `deno task --config salvia/deno.json`, CWD is usually the project root.
|
|
29
|
+
// But if running from inside salvia/, it's different.
|
|
30
|
+
const ROOT_DIR = Deno.cwd().endsWith("/salvia") ? "." : "salvia";
|
|
31
|
+
const ISLANDS_DIR = `${ROOT_DIR}/app/islands`;
|
|
32
|
+
const PAGES_DIR = `${ROOT_DIR}/app/pages`;
|
|
33
|
+
const COMPONENTS_DIR = `${ROOT_DIR}/app/components`;
|
|
34
|
+
const SSR_OUTPUT_DIR = `${ROOT_DIR}/server`;
|
|
35
|
+
const CLIENT_OUTPUT_DIR = `${ROOT_DIR}/../public/assets/islands`;
|
|
36
|
+
const WATCH_MODE = Deno.args.includes("--watch");
|
|
37
|
+
const VERBOSE = Deno.args.includes("--verbose");
|
|
38
|
+
|
|
39
|
+
// ============================================
|
|
40
|
+
// SSR Islands Build
|
|
41
|
+
// ============================================
|
|
42
|
+
|
|
43
|
+
interface IslandFile {
|
|
44
|
+
path: string;
|
|
45
|
+
name: string;
|
|
46
|
+
clientOnly: boolean;
|
|
47
|
+
isPage: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function findIslandFiles(): Promise<IslandFile[]> {
|
|
51
|
+
const files: IslandFile[] = [];
|
|
52
|
+
|
|
53
|
+
const scan = async (dir: string, isPage: boolean) => {
|
|
54
|
+
try {
|
|
55
|
+
for await (const entry of Deno.readDir(dir)) {
|
|
56
|
+
if (entry.isFile && (entry.name.endsWith(".tsx") || entry.name.endsWith(".jsx") || entry.name.endsWith(".js"))) {
|
|
57
|
+
if (entry.name.startsWith("_")) continue; // Skip internal files
|
|
58
|
+
const path = `${dir}/${entry.name}`;
|
|
59
|
+
const content = await Deno.readTextFile(path);
|
|
60
|
+
const clientOnly = content.trimStart().startsWith('"client only"') ||
|
|
61
|
+
content.trimStart().startsWith("'client only'");
|
|
62
|
+
const name = entry.name.replace(/\.(tsx|jsx|js)$/, "");
|
|
63
|
+
files.push({ path, name, clientOnly, isPage });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
// Directory might not exist
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
await scan(ISLANDS_DIR, false);
|
|
72
|
+
await scan(PAGES_DIR, true);
|
|
73
|
+
|
|
74
|
+
if (files.length === 0) {
|
|
75
|
+
console.log("📁 No components found in app/islands or app/pages.");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return files;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function buildSSR() {
|
|
82
|
+
const islandFiles = await findIslandFiles();
|
|
83
|
+
|
|
84
|
+
if (islandFiles.length === 0) {
|
|
85
|
+
console.log("⚠️ No Island components found. Skipping SSR build.");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const ssrFiles = islandFiles.filter(f => !f.clientOnly);
|
|
90
|
+
const clientFiles = islandFiles.filter(f => !f.isPage);
|
|
91
|
+
|
|
92
|
+
if (VERBOSE) {
|
|
93
|
+
console.log("🔍 SSR targets:", ssrFiles.map(f => f.name));
|
|
94
|
+
console.log("🔍 Client targets:", clientFiles.map(f => f.name));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Create output directories
|
|
98
|
+
await Deno.mkdir(SSR_OUTPUT_DIR, { recursive: true });
|
|
99
|
+
await Deno.mkdir(CLIENT_OUTPUT_DIR, { recursive: true });
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
// SSR bundle (for QuickJS) - single bundle with all components
|
|
103
|
+
if (ssrFiles.length > 0) {
|
|
104
|
+
// Create a virtual entry point that exports all components
|
|
105
|
+
// Get just the filename from the path
|
|
106
|
+
const entryCode = ssrFiles.map(f => {
|
|
107
|
+
let importPath = f.path;
|
|
108
|
+
// f.path is like "../app/islands/Counter.tsx" or "../app/pages/Home.tsx"
|
|
109
|
+
// We are writing _ssr_entry.js to ISLANDS_DIR ("../app/islands")
|
|
110
|
+
|
|
111
|
+
if (f.path.startsWith(ISLANDS_DIR)) {
|
|
112
|
+
importPath = `./${f.path.split("/").pop()}`;
|
|
113
|
+
} else if (f.path.startsWith(PAGES_DIR)) {
|
|
114
|
+
// From ../app/islands to ../app/pages is ../pages
|
|
115
|
+
importPath = `../pages/${f.path.split("/").pop()}`;
|
|
116
|
+
}
|
|
117
|
+
return `import ${f.name} from "${importPath}";`;
|
|
118
|
+
}).join("\n") + `
|
|
119
|
+
import { h } from "preact";
|
|
120
|
+
import renderToString from "preact-render-to-string";
|
|
121
|
+
|
|
122
|
+
// Salvia SSR Runtime
|
|
123
|
+
const components = {
|
|
124
|
+
${ssrFiles.map(f => ` "${f.name}": ${f.name}`).join(",\n")}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
globalThis.SalviaSSR = {
|
|
128
|
+
render: function(name, props) {
|
|
129
|
+
const Component = components[name];
|
|
130
|
+
if (!Component) {
|
|
131
|
+
throw new Error("Component not found: " + name);
|
|
132
|
+
}
|
|
133
|
+
const vnode = h(Component, props);
|
|
134
|
+
return renderToString(vnode);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
export default {}; // Ensure it's a module
|
|
138
|
+
`;
|
|
139
|
+
const entryPath = `${ISLANDS_DIR}/_ssr_entry.js`;
|
|
140
|
+
await Deno.writeTextFile(entryPath, entryCode);
|
|
141
|
+
|
|
142
|
+
await esbuild.build({
|
|
143
|
+
entryPoints: [entryPath],
|
|
144
|
+
bundle: true,
|
|
145
|
+
format: "iife",
|
|
146
|
+
outfile: `${SSR_OUTPUT_DIR}/ssr_bundle.js`,
|
|
147
|
+
platform: "neutral",
|
|
148
|
+
plugins: [...denoPlugins({ configPath: CONFIG_PATH })],
|
|
149
|
+
external: [],
|
|
150
|
+
jsx: "automatic",
|
|
151
|
+
jsxImportSource: "preact",
|
|
152
|
+
banner: {
|
|
153
|
+
js: `// Salvia SSR Bundle - Generated at ${new Date().toISOString()}`,
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Clean up temp file
|
|
158
|
+
await Deno.remove(entryPath);
|
|
159
|
+
|
|
160
|
+
console.log(`✅ SSR bundle built: ${SSR_OUTPUT_DIR}/ssr_bundle.js (${ssrFiles.map(f => f.name).join(", ")})`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Client bundle (for hydration) - all files
|
|
164
|
+
// We need to wrap each component with a mount function
|
|
165
|
+
const clientEntryPoints = [];
|
|
166
|
+
|
|
167
|
+
for (const file of clientFiles) {
|
|
168
|
+
const filename = file.path.split("/").pop();
|
|
169
|
+
const wrapperCode = `
|
|
170
|
+
import Component from "./${filename}";
|
|
171
|
+
import { h, hydrate, render } from "preact";
|
|
172
|
+
|
|
173
|
+
export function mount(element, props, options) {
|
|
174
|
+
const vnode = h(Component, props);
|
|
175
|
+
if (options && options.hydrate) {
|
|
176
|
+
hydrate(vnode, element);
|
|
177
|
+
} else {
|
|
178
|
+
render(vnode, element);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
`;
|
|
182
|
+
const wrapperPath = `${ISLANDS_DIR}/_client_${file.name}.js`;
|
|
183
|
+
await Deno.writeTextFile(wrapperPath, wrapperCode);
|
|
184
|
+
clientEntryPoints.push({ in: wrapperPath, out: file.name });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
await esbuild.build({
|
|
189
|
+
entryPoints: clientEntryPoints,
|
|
190
|
+
bundle: true,
|
|
191
|
+
format: "esm",
|
|
192
|
+
outdir: CLIENT_OUTPUT_DIR,
|
|
193
|
+
platform: "browser",
|
|
194
|
+
plugins: [...denoPlugins({ configPath: CONFIG_PATH })],
|
|
195
|
+
external: ["preact", "preact/hooks", "preact/jsx-runtime"],
|
|
196
|
+
jsx: "automatic",
|
|
197
|
+
jsxImportSource: "preact",
|
|
198
|
+
minify: true,
|
|
199
|
+
banner: {
|
|
200
|
+
js: `// Salvia Client Islands - Generated at ${new Date().toISOString()}`,
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
} finally {
|
|
204
|
+
// Clean up temp files
|
|
205
|
+
for (const entry of clientEntryPoints) {
|
|
206
|
+
try {
|
|
207
|
+
await Deno.remove(entry.in);
|
|
208
|
+
} catch {
|
|
209
|
+
// Ignore if file doesn't exist
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
console.log(`✅ Client Islands built: ${CLIENT_OUTPUT_DIR}/ (${clientFiles.map(f => f.name).join(", ")})`);
|
|
215
|
+
|
|
216
|
+
// Generate manifest (which Islands are client only)
|
|
217
|
+
const manifest = Object.fromEntries(
|
|
218
|
+
islandFiles.map(f => [f.name, { clientOnly: f.clientOnly, serverOnly: f.isPage }])
|
|
219
|
+
);
|
|
220
|
+
await Deno.writeTextFile(
|
|
221
|
+
`${SSR_OUTPUT_DIR}/manifest.json`,
|
|
222
|
+
JSON.stringify(manifest, null, 2)
|
|
223
|
+
);
|
|
224
|
+
console.log(`✅ Manifest generated: ${SSR_OUTPUT_DIR}/manifest.json`);
|
|
225
|
+
|
|
226
|
+
// Copy islands.js loader
|
|
227
|
+
try {
|
|
228
|
+
const islandsJsPath = new URL("../javascripts/islands.js", import.meta.url).pathname;
|
|
229
|
+
const islandsJsOutput = `${ROOT_DIR}/../public/assets/javascripts/islands.js`;
|
|
230
|
+
await Deno.mkdir(`${ROOT_DIR}/../public/assets/javascripts`, { recursive: true });
|
|
231
|
+
await Deno.copyFile(islandsJsPath, islandsJsOutput);
|
|
232
|
+
console.log(`✅ Loader copied: ${islandsJsOutput}`);
|
|
233
|
+
} catch (e) {
|
|
234
|
+
console.warn("⚠️ Failed to copy islands.js loader:", e);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
} catch (error) {
|
|
238
|
+
const e = error as Error;
|
|
239
|
+
console.error("❌ SSR build error:", e.message || error);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ============================================
|
|
244
|
+
// Main Build
|
|
245
|
+
// ============================================
|
|
246
|
+
|
|
247
|
+
async function build() {
|
|
248
|
+
await Promise.all([
|
|
249
|
+
buildSSR(),
|
|
250
|
+
]);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function watch() {
|
|
254
|
+
console.log("👀 Watching for file changes...");
|
|
255
|
+
|
|
256
|
+
// Watch Islands source
|
|
257
|
+
const watchDirs = [ISLANDS_DIR, PAGES_DIR, COMPONENTS_DIR, "./app/views"];
|
|
258
|
+
|
|
259
|
+
for (const dir of watchDirs) {
|
|
260
|
+
(async () => {
|
|
261
|
+
try {
|
|
262
|
+
const watcher = Deno.watchFs(dir);
|
|
263
|
+
let debounceTimer: number | undefined;
|
|
264
|
+
|
|
265
|
+
for await (const event of watcher) {
|
|
266
|
+
// Ignore generated files
|
|
267
|
+
if (event.paths.some(p => p.includes("_ssr_entry.js") || p.includes("_client_"))) {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (event.kind === "modify" || event.kind === "create" || event.kind === "remove") {
|
|
272
|
+
clearTimeout(debounceTimer);
|
|
273
|
+
debounceTimer = setTimeout(async () => {
|
|
274
|
+
console.log(`🔄 Changes detected in ${dir}, rebuilding...`);
|
|
275
|
+
await build();
|
|
276
|
+
}, 100);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
} catch {
|
|
280
|
+
// Skip if directory doesn't exist
|
|
281
|
+
}
|
|
282
|
+
})();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Wait indefinitely
|
|
286
|
+
await new Promise(() => {});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Main execution
|
|
290
|
+
console.log("🌿 Salvia Build (SSR + Tailwind)");
|
|
291
|
+
console.log("================================");
|
|
292
|
+
await build();
|
|
293
|
+
|
|
294
|
+
if (WATCH_MODE) {
|
|
295
|
+
await watch();
|
|
296
|
+
} else {
|
|
297
|
+
await esbuild.stop();
|
|
298
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"imports": {
|
|
3
|
+
"preact": "https://esm.sh/preact@10.19.3",
|
|
4
|
+
"preact/hooks": "https://esm.sh/preact@10.19.3/hooks",
|
|
5
|
+
"preact/jsx-runtime": "https://esm.sh/preact@10.19.3/jsx-runtime",
|
|
6
|
+
"preact-render-to-string": "https://esm.sh/preact-render-to-string@6.3.1?external=preact",
|
|
7
|
+
"@preact/signals": "https://esm.sh/@preact/signals@1.2.2?external=preact",
|
|
8
|
+
"@hotwired/turbo": "https://esm.sh/@hotwired/turbo@8.0.0",
|
|
9
|
+
"@/": "./app/"
|
|
10
|
+
},
|
|
11
|
+
"compilerOptions": {
|
|
12
|
+
"jsx": "react-jsx",
|
|
13
|
+
"jsxImportSource": "preact"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "5",
|
|
3
|
+
"specifiers": {
|
|
4
|
+
"jsr:@luca/esbuild-deno-loader@0.11": "0.11.1",
|
|
5
|
+
"jsr:@std/bytes@^1.0.2": "1.0.6",
|
|
6
|
+
"jsr:@std/encoding@^1.0.5": "1.0.10",
|
|
7
|
+
"jsr:@std/internal@^1.0.12": "1.0.12",
|
|
8
|
+
"jsr:@std/path@^1.0.6": "1.1.3"
|
|
9
|
+
},
|
|
10
|
+
"jsr": {
|
|
11
|
+
"@luca/esbuild-deno-loader@0.11.1": {
|
|
12
|
+
"integrity": "dc020d16d75b591f679f6b9288b10f38bdb4f24345edb2f5732affa1d9885267",
|
|
13
|
+
"dependencies": [
|
|
14
|
+
"jsr:@std/bytes",
|
|
15
|
+
"jsr:@std/encoding",
|
|
16
|
+
"jsr:@std/path"
|
|
17
|
+
]
|
|
18
|
+
},
|
|
19
|
+
"@std/bytes@1.0.6": {
|
|
20
|
+
"integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a"
|
|
21
|
+
},
|
|
22
|
+
"@std/encoding@1.0.10": {
|
|
23
|
+
"integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1"
|
|
24
|
+
},
|
|
25
|
+
"@std/internal@1.0.12": {
|
|
26
|
+
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
|
|
27
|
+
},
|
|
28
|
+
"@std/path@1.1.3": {
|
|
29
|
+
"integrity": "b015962d82a5e6daea980c32b82d2c40142149639968549c649031a230b1afb3",
|
|
30
|
+
"dependencies": [
|
|
31
|
+
"jsr:@std/internal"
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"redirects": {
|
|
36
|
+
"https://esm.sh/@preact/signals-core@^1.4.0?target=denonext": "https://esm.sh/@preact/signals-core@1.12.1?target=denonext"
|
|
37
|
+
},
|
|
38
|
+
"remote": {
|
|
39
|
+
"https://deno.land/x/denoflate@1.2.1/mod.ts": "f5628e44b80b3d80ed525afa2ba0f12408e3849db817d47a883b801f9ce69dd6",
|
|
40
|
+
"https://deno.land/x/denoflate@1.2.1/pkg/denoflate.js": "b9f9ad9457d3f12f28b1fb35c555f57443427f74decb403113d67364e4f2caf4",
|
|
41
|
+
"https://deno.land/x/denoflate@1.2.1/pkg/denoflate_bg.wasm.js": "d581956245407a2115a3d7e8d85a9641c032940a8e810acbd59ca86afd34d44d",
|
|
42
|
+
"https://deno.land/x/esbuild@v0.24.2/mod.js": "8d1e46a6494585235b0514d37743ee48a4f6f0b8e00fca9d0a2e371914b1df0e",
|
|
43
|
+
"https://esm.sh/@preact/signals-core@1.12.1/denonext/signals-core.mjs": "7ec7397ff664d18002330248c74ad39a8f65ad2db3a2ecdfdc9874d3585cd32a",
|
|
44
|
+
"https://esm.sh/@preact/signals-core@1.12.1?target=denonext": "cc0c788b17ca5e38bc693aceccad467cdcd2c576974444d0bf65309619b4bf20",
|
|
45
|
+
"https://esm.sh/@preact/signals@1.2.2/X-ZXByZWFjdA/denonext/signals.mjs": "b588b595eda1161dbb91eab1170c293d89194faa41a4dfc50c04986869f79f6a",
|
|
46
|
+
"https://esm.sh/@preact/signals@1.2.2?external=preact": "fc9fb49e945d8e44b00d8fbed96594355ad0cefc580a5da9f6488f617dca8773",
|
|
47
|
+
"https://esm.sh/preact-render-to-string@6.3.1/X-ZXByZWFjdA/denonext/preact-render-to-string.mjs": "859e9f3dc137a53c17542bbed4cf8accdca81a2895d7a52d1fca38e6c5c00c90",
|
|
48
|
+
"https://esm.sh/preact-render-to-string@6.3.1?external=preact": "3bc8562bab5e16dd3c984d996abdc37d25880319bb9eac3169e97a9c7fa51243",
|
|
49
|
+
"https://esm.sh/preact@10.19.3": "e665fa7cfbac33461e7ecf79f8b361a8f831b3e7edc8089d1f95e05fed6a8be1",
|
|
50
|
+
"https://esm.sh/preact@10.19.3/denonext/hooks.mjs": "1e6dcdc31a6d4c4fecf1665538f892bb33bbee8fee0f3c664b9db5362871849f",
|
|
51
|
+
"https://esm.sh/preact@10.19.3/denonext/jsx-runtime.mjs": "a151a1f662b5e3023385100c46e2c4142489d3b982cb094a08af8a3ca7b30c1d",
|
|
52
|
+
"https://esm.sh/preact@10.19.3/denonext/preact.mjs": "6233d303372951bb69428bcd7fd4a6617fc721edee334f228ae8b2e5ab6a2e7f",
|
|
53
|
+
"https://esm.sh/preact@10.19.3/hooks": "ec6d1409b12bce994ec7f0711b17e41424e339727af83fb471acc63952e35278",
|
|
54
|
+
"https://esm.sh/preact@10.19.3/jsx-runtime": "c0a489946b2a492ed590365ce1dbb2606adef27635722687b7d6320f57faf435"
|
|
55
|
+
}
|
|
56
|
+
}
|