sinatra-kagero 1.0.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/CHANGELOG.md +7 -0
- data/LICENSE +21 -0
- data/README.md +36 -0
- data/lib/sinatra/kagero/command.rb +133 -0
- data/lib/sinatra/kagero/page.rb +84 -0
- data/lib/sinatra/kagero/props.rb +115 -0
- data/lib/sinatra/kagero/runtime.rb +152 -0
- data/lib/sinatra/kagero/version.rb +7 -0
- data/lib/sinatra/kagero.rb +110 -0
- data/runtime/kagero.js +138 -0
- metadata +122 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d3f5f3c5e03dd426b4ea2f76d4dfbbba1220d5d366cbc368784675fd27c6944d
|
|
4
|
+
data.tar.gz: 7b71df421ac13b8575d459d0a0a25eadd6108b26271f5581e80f28d397fcc5d4
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e99c1e6efea707ff1d72ff992a2bb18ac80a214f8bf99d63a645c664b471eba89f03f6f37702f91291dd262ebc019991141ba7a01d2d476e536f6f52e83deace
|
|
7
|
+
data.tar.gz: 49012fc6c23ab5d6923b84c826bdfdec376f569145b94961855d48b0dba686eed8c68846783014ee88126cd026194be576c6794b9136be20917e468d6dcb0231
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.0.0 — 2026-06-25
|
|
4
|
+
|
|
5
|
+
- Extract Kagero from `sinatra-inertia` into its own gem.
|
|
6
|
+
- Provide Ruby page classes, props validation, command validation, and the hidden Kagero browser runtime.
|
|
7
|
+
- Keep `sinatra-inertia` as the lower-level Inertia protocol adapter.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kazuhiro Homma
|
|
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.
|
data/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# sinatra-kagero
|
|
2
|
+
|
|
3
|
+
Ruby-way Inertia-style application layer for Sinatra and Homura.
|
|
4
|
+
|
|
5
|
+
`sinatra-kagero` sits above `sinatra-inertia`. The lower gem owns the
|
|
6
|
+
Inertia v2 wire protocol; Kagero owns the Ruby authoring model:
|
|
7
|
+
|
|
8
|
+
- `Kagero::Page` classes render HTML with Phlex
|
|
9
|
+
- page props are declared and validated in Ruby
|
|
10
|
+
- form input is modeled with `Kagero::Command`
|
|
11
|
+
- a hidden browser runtime handles visits, forms, history, scroll, partial reloads, and asset-version hard reloads
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
gem "sinatra-kagero"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
require "sinatra/base"
|
|
23
|
+
require "sinatra/kagero"
|
|
24
|
+
|
|
25
|
+
class App < Sinatra::Base
|
|
26
|
+
register Sinatra::Kagero
|
|
27
|
+
|
|
28
|
+
get "/" do
|
|
29
|
+
page(Pages::Todos::Index, todos: Todo.all, errors: {})
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Kagero intentionally keeps JavaScript out of the main application authoring
|
|
35
|
+
surface. The client runtime exists, but route handlers, pages, props, forms,
|
|
36
|
+
validation, and persistence remain Ruby.
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sinatra
|
|
4
|
+
module Kagero
|
|
5
|
+
class Command
|
|
6
|
+
class << self
|
|
7
|
+
def inherited(subclass)
|
|
8
|
+
super
|
|
9
|
+
subclass.instance_variable_set(:@kagero_attributes, kagero_attributes.dup)
|
|
10
|
+
subclass.instance_variable_set(:@kagero_validations, kagero_validations.dup)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def attribute(name, type = String, required: false, default: nil)
|
|
14
|
+
kagero_attributes <<
|
|
15
|
+
{
|
|
16
|
+
name: name.to_sym,
|
|
17
|
+
type: type,
|
|
18
|
+
required: required,
|
|
19
|
+
default: default
|
|
20
|
+
}
|
|
21
|
+
attr_reader(name)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def validates_presence_of(*names, message: "is required")
|
|
25
|
+
names.each do |name|
|
|
26
|
+
kagero_validations <<
|
|
27
|
+
{
|
|
28
|
+
kind: :presence,
|
|
29
|
+
name: name.to_sym,
|
|
30
|
+
message: message
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def validates_length_of(name, maximum:, message: nil)
|
|
36
|
+
kagero_validations <<
|
|
37
|
+
{
|
|
38
|
+
kind: :length,
|
|
39
|
+
name: name.to_sym,
|
|
40
|
+
maximum: maximum,
|
|
41
|
+
message: message || "must be #{maximum} characters or less"
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def kagero_attributes
|
|
46
|
+
@kagero_attributes ||= []
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def kagero_validations
|
|
50
|
+
@kagero_validations ||= []
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
attr_reader :errors
|
|
55
|
+
|
|
56
|
+
def initialize(input = {})
|
|
57
|
+
@errors = {}
|
|
58
|
+
self.class.kagero_attributes.each do |attribute|
|
|
59
|
+
name = attribute.fetch(:name)
|
|
60
|
+
value = fetch_value(input, name)
|
|
61
|
+
value = default_value(attribute.fetch(:default)) if missing?(value)
|
|
62
|
+
value = coerce_value(value, attribute.fetch(:type))
|
|
63
|
+
instance_variable_set(:"@#{name}", value)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def valid?
|
|
68
|
+
@errors = {}
|
|
69
|
+
self.class.kagero_validations.each { |validation| apply_validation(validation) }
|
|
70
|
+
@errors.empty?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def invalid?
|
|
74
|
+
!valid?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def to_h
|
|
78
|
+
self
|
|
79
|
+
.class
|
|
80
|
+
.kagero_attributes
|
|
81
|
+
.map do |attribute|
|
|
82
|
+
name = attribute.fetch(:name)
|
|
83
|
+
[name, instance_variable_get(:"@#{name}")]
|
|
84
|
+
end
|
|
85
|
+
.to_h
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def fetch_value(input, name)
|
|
91
|
+
return input[name] if input.respond_to?(:key?) && input.key?(name)
|
|
92
|
+
|
|
93
|
+
string_name = name.to_s
|
|
94
|
+
return input[string_name] if input.respond_to?(:key?) && input.key?(string_name)
|
|
95
|
+
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def missing?(value)
|
|
100
|
+
value.nil?
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def default_value(default)
|
|
104
|
+
default.respond_to?(:call) ? default.call : default
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def coerce_value(value, type)
|
|
108
|
+
return value if value.nil?
|
|
109
|
+
return value.to_s if type == String
|
|
110
|
+
return value.to_i if type == Integer
|
|
111
|
+
return value == true || value.to_s == "true" || value.to_s == "1" if type == :boolean
|
|
112
|
+
|
|
113
|
+
value
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def apply_validation(validation)
|
|
117
|
+
name = validation.fetch(:name)
|
|
118
|
+
value = instance_variable_get(:"@#{name}")
|
|
119
|
+
case validation.fetch(:kind)
|
|
120
|
+
when :presence
|
|
121
|
+
add_error(name, validation.fetch(:message)) if value.nil? || value.to_s.strip.empty?
|
|
122
|
+
when :length
|
|
123
|
+
maximum = validation.fetch(:maximum)
|
|
124
|
+
add_error(name, validation.fetch(:message)) if value && value.to_s.length > maximum
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def add_error(name, message)
|
|
129
|
+
@errors[name] ||= message
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "phlex"
|
|
4
|
+
|
|
5
|
+
module Sinatra
|
|
6
|
+
module Kagero
|
|
7
|
+
class Page < Phlex::HTML
|
|
8
|
+
class << self
|
|
9
|
+
def inherited(subclass)
|
|
10
|
+
super
|
|
11
|
+
subclass.instance_variable_set(:@kagero_props_schema, kagero_props_schema.dup)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def props(&block)
|
|
15
|
+
kagero_props_schema.instance_eval(&block) if block
|
|
16
|
+
kagero_props_schema
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def prop(name, type = nil, **options)
|
|
20
|
+
kagero_props_schema.prop(name, type, **options)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def title(value = nil, &block)
|
|
24
|
+
@kagero_title = block || value unless value.nil? && block.nil?
|
|
25
|
+
@kagero_title
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def kagero_component_name
|
|
29
|
+
name.to_s.sub(/\APages::/, "").gsub("::", "/")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def kagero_props_schema
|
|
33
|
+
@kagero_props_schema ||= Props::Schema.new
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
attr_reader :kagero_props
|
|
38
|
+
|
|
39
|
+
def initialize(**props)
|
|
40
|
+
@kagero_props = self.class.kagero_props_schema.coerce(props)
|
|
41
|
+
@kagero_props.each { |name, value| instance_variable_set(:"@#{name}", value) }
|
|
42
|
+
super()
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def kagero_title
|
|
46
|
+
configured = self.class.title
|
|
47
|
+
return instance_exec(&configured).to_s if configured.respond_to?(:call)
|
|
48
|
+
return configured.to_s unless configured.nil?
|
|
49
|
+
|
|
50
|
+
self.class.kagero_component_name
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def kagero_form(action:, method: "post", **attributes, &block)
|
|
56
|
+
attributes = kagero_data(attributes, "kagero" => "true")
|
|
57
|
+
attributes[:action] = action
|
|
58
|
+
attributes[:method] = method
|
|
59
|
+
form(**attributes, &block)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def kagero_link(href:, **attributes, &block)
|
|
63
|
+
attributes = kagero_data(attributes, "kagero" => "true")
|
|
64
|
+
attributes[:href] = href
|
|
65
|
+
a(**attributes, &block)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def kagero_partial_button(only:, **attributes, &block)
|
|
69
|
+
attributes = kagero_data(
|
|
70
|
+
attributes,
|
|
71
|
+
"kagero-reload" => "true",
|
|
72
|
+
"kagero-only" => Array(only).join(",")
|
|
73
|
+
)
|
|
74
|
+
attributes[:type] ||= "button"
|
|
75
|
+
button(**attributes, &block)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def kagero_data(attributes, data)
|
|
79
|
+
data.each { |key, value| attributes[:"data-#{key}"] = value }
|
|
80
|
+
attributes
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sinatra
|
|
4
|
+
module Kagero
|
|
5
|
+
module Props
|
|
6
|
+
MISSING = Object.new
|
|
7
|
+
|
|
8
|
+
class ValidationError < ArgumentError
|
|
9
|
+
attr_reader :errors
|
|
10
|
+
|
|
11
|
+
def initialize(errors)
|
|
12
|
+
@errors = errors
|
|
13
|
+
super(errors.map { |name, message| "#{name}: #{message}" }.join(", "))
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class Field
|
|
18
|
+
attr_reader :name, :type, :default
|
|
19
|
+
|
|
20
|
+
def initialize(name, type, required:, default:)
|
|
21
|
+
@name = name.to_sym
|
|
22
|
+
@type = type
|
|
23
|
+
@required = required
|
|
24
|
+
@default = default
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def required?
|
|
28
|
+
@required
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def coerce(input, errors)
|
|
32
|
+
value = fetch_value(input)
|
|
33
|
+
if value.equal?(MISSING)
|
|
34
|
+
return default_value unless default.equal?(MISSING)
|
|
35
|
+
errors[name] = "is required" if required?
|
|
36
|
+
return nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
unless valid_type?(value)
|
|
40
|
+
errors[name] = "must be #{type_name}"
|
|
41
|
+
return value
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
value
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def fetch_value(input)
|
|
50
|
+
return input[name] if input.key?(name)
|
|
51
|
+
string_name = name.to_s
|
|
52
|
+
return input[string_name] if input.key?(string_name)
|
|
53
|
+
|
|
54
|
+
MISSING
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def default_value
|
|
58
|
+
default.respond_to?(:call) ? default.call : default
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def valid_type?(value)
|
|
62
|
+
return true if type.nil?
|
|
63
|
+
return value == true || value == false if type == :bool || type == :boolean
|
|
64
|
+
return value.is_a?(Array) if type == :array
|
|
65
|
+
return value.is_a?(Hash) if type == :hash
|
|
66
|
+
|
|
67
|
+
type === value
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def type_name
|
|
71
|
+
case type
|
|
72
|
+
when :bool, :boolean
|
|
73
|
+
"Boolean"
|
|
74
|
+
when :array
|
|
75
|
+
"Array"
|
|
76
|
+
when :hash
|
|
77
|
+
"Hash"
|
|
78
|
+
else
|
|
79
|
+
type.to_s
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
class Schema
|
|
85
|
+
def initialize(fields = [])
|
|
86
|
+
@fields = fields
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def dup
|
|
90
|
+
self.class.new(@fields.dup)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def prop(name, type = nil, required: true, default: MISSING)
|
|
94
|
+
@fields << Field.new(name, type, required: required, default: default)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def coerce(input)
|
|
98
|
+
errors = {}
|
|
99
|
+
output = {}
|
|
100
|
+
@fields.each do |field|
|
|
101
|
+
output[field.name] = field.coerce(input, errors)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
raise ValidationError, errors unless errors.empty?
|
|
105
|
+
|
|
106
|
+
output
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def to_h
|
|
110
|
+
@fields.map { |field| [field.name, field.type] }.to_h
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sinatra
|
|
4
|
+
module Kagero
|
|
5
|
+
module Runtime
|
|
6
|
+
SOURCE = <<~JS
|
|
7
|
+
const root = document.querySelector("[data-kagero-root]");
|
|
8
|
+
let currentPage = null;
|
|
9
|
+
let currentScroll = { left: 0, top: 0 };
|
|
10
|
+
|
|
11
|
+
function readInitialPage() {
|
|
12
|
+
if (!root) return null;
|
|
13
|
+
const raw = root.getAttribute("data-page");
|
|
14
|
+
if (!raw) return null;
|
|
15
|
+
return JSON.parse(raw);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function pageHtml(page) {
|
|
19
|
+
return page && page.props && page.props.kagero && page.props.kagero.html;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function rememberScroll() {
|
|
23
|
+
currentScroll = { left: window.scrollX, top: window.scrollY };
|
|
24
|
+
if (history.state && history.state.kagero) {
|
|
25
|
+
history.replaceState({ ...history.state, scroll: currentScroll }, "", location.href);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function applyPage(page, { replace = false, preserveScroll = false } = {}) {
|
|
30
|
+
if (!root) return;
|
|
31
|
+
const html = pageHtml(page);
|
|
32
|
+
if (typeof html === "string") root.innerHTML = html;
|
|
33
|
+
root.setAttribute("data-page", JSON.stringify(page));
|
|
34
|
+
currentPage = page;
|
|
35
|
+
|
|
36
|
+
const state = { kagero: true, page, scroll: preserveScroll ? currentScroll : { left: 0, top: 0 } };
|
|
37
|
+
if (replace) history.replaceState(state, "", page.url);
|
|
38
|
+
else history.pushState(state, "", page.url);
|
|
39
|
+
|
|
40
|
+
if (!preserveScroll) window.scrollTo(0, 0);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function visit(url, options = {}) {
|
|
44
|
+
rememberScroll();
|
|
45
|
+
const headers = new Headers(options.headers || {});
|
|
46
|
+
headers.set("X-Inertia", "true");
|
|
47
|
+
headers.set("X-Inertia-Version", currentPage ? currentPage.version : "");
|
|
48
|
+
headers.set("X-Requested-With", "XMLHttpRequest");
|
|
49
|
+
headers.set("Accept", "application/json, text/html;q=0.9");
|
|
50
|
+
|
|
51
|
+
const response = await fetch(url, {
|
|
52
|
+
method: options.method || "GET",
|
|
53
|
+
body: options.body,
|
|
54
|
+
headers,
|
|
55
|
+
credentials: "same-origin",
|
|
56
|
+
redirect: "follow"
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (response.status === 409 && response.headers.get("X-Inertia-Location")) {
|
|
60
|
+
location.href = response.headers.get("X-Inertia-Location");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!response.ok) throw new Error(`Kagero visit failed: ${response.status}`);
|
|
65
|
+
|
|
66
|
+
const page = await response.json();
|
|
67
|
+
applyPage(page, {
|
|
68
|
+
replace: options.replace === true,
|
|
69
|
+
preserveScroll: options.preserveScroll === true
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function formBody(form) {
|
|
74
|
+
const method = (form.getAttribute("method") || "GET").toUpperCase();
|
|
75
|
+
if (method === "GET") return null;
|
|
76
|
+
return new FormData(form);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function formUrl(form) {
|
|
80
|
+
const action = form.getAttribute("action") || location.href;
|
|
81
|
+
const method = (form.getAttribute("method") || "GET").toUpperCase();
|
|
82
|
+
if (method !== "GET") return action;
|
|
83
|
+
|
|
84
|
+
const url = new URL(action, location.href);
|
|
85
|
+
const data = new FormData(form);
|
|
86
|
+
for (const [key, value] of data.entries()) url.searchParams.set(key, value);
|
|
87
|
+
return url.toString();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
document.addEventListener("click", (event) => {
|
|
91
|
+
const target = event.target;
|
|
92
|
+
if (!target || !target.closest) return;
|
|
93
|
+
|
|
94
|
+
const link = target.closest("a[data-kagero]");
|
|
95
|
+
if (link) {
|
|
96
|
+
event.preventDefault();
|
|
97
|
+
visit(link.href, {
|
|
98
|
+
replace: link.dataset.kageroReplace === "true",
|
|
99
|
+
preserveScroll: link.dataset.kageroPreserveScroll === "true"
|
|
100
|
+
});
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const reload = target.closest("[data-kagero-reload]");
|
|
105
|
+
if (reload) {
|
|
106
|
+
event.preventDefault();
|
|
107
|
+
const only = reload.dataset.kageroOnly || "";
|
|
108
|
+
const headers = {};
|
|
109
|
+
if (currentPage && only) {
|
|
110
|
+
headers["X-Inertia-Partial-Component"] = currentPage.component;
|
|
111
|
+
headers["X-Inertia-Partial-Data"] = only;
|
|
112
|
+
}
|
|
113
|
+
visit(location.href, { replace: true, preserveScroll: true, headers });
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
document.addEventListener("submit", (event) => {
|
|
118
|
+
const target = event.target;
|
|
119
|
+
if (!target || !target.closest) return;
|
|
120
|
+
const form = target.closest("form[data-kagero]");
|
|
121
|
+
if (!form) return;
|
|
122
|
+
|
|
123
|
+
event.preventDefault();
|
|
124
|
+
visit(formUrl(form), {
|
|
125
|
+
method: (form.getAttribute("method") || "GET").toUpperCase(),
|
|
126
|
+
body: formBody(form),
|
|
127
|
+
preserveScroll: form.dataset.kageroPreserveScroll === "true"
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
window.addEventListener("popstate", (event) => {
|
|
132
|
+
if (event.state && event.state.kagero && event.state.page) {
|
|
133
|
+
currentPage = event.state.page;
|
|
134
|
+
const html = pageHtml(currentPage);
|
|
135
|
+
if (typeof html === "string") root.innerHTML = html;
|
|
136
|
+
const scroll = event.state.scroll || { left: 0, top: 0 };
|
|
137
|
+
window.scrollTo(scroll.left, scroll.top);
|
|
138
|
+
} else {
|
|
139
|
+
location.reload();
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
currentPage = readInitialPage();
|
|
144
|
+
if (currentPage) {
|
|
145
|
+
history.replaceState({ kagero: true, page: currentPage, scroll: { left: 0, top: 0 } }, "", currentPage.url);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
window.Kagero = { visit };
|
|
149
|
+
JS
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "phlex"
|
|
5
|
+
require "sinatra/inertia"
|
|
6
|
+
|
|
7
|
+
require "sinatra/kagero/command"
|
|
8
|
+
require "sinatra/kagero/props"
|
|
9
|
+
require "sinatra/kagero/page"
|
|
10
|
+
require "sinatra/kagero/runtime"
|
|
11
|
+
require "sinatra/kagero/version"
|
|
12
|
+
|
|
13
|
+
module Sinatra
|
|
14
|
+
# Ruby-way page API layered on the Inertia protocol.
|
|
15
|
+
module Kagero
|
|
16
|
+
def self.registered(app)
|
|
17
|
+
app.register(Sinatra::Inertia)
|
|
18
|
+
app.set(:kagero_root_id, "app") unless app.respond_to?(:kagero_root_id)
|
|
19
|
+
app.helpers(Helpers)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
module Helpers
|
|
23
|
+
def page(page_class, **props)
|
|
24
|
+
render_kagero_page(page_class, props)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def render_page(page_class, **props)
|
|
28
|
+
render_kagero_page(page_class, props)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def redirect_page(path, page_class, **props)
|
|
32
|
+
return render_kagero_page(page_class, props, url: path) if inertia_request?
|
|
33
|
+
|
|
34
|
+
redirect(to(path), 303)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def render_kagero_page(page_class, props, url: request.fullpath)
|
|
40
|
+
component = page_class.kagero_component_name
|
|
41
|
+
page_instance = page_class.new(**props)
|
|
42
|
+
page_props = page_instance.kagero_props
|
|
43
|
+
body_html = page_instance.call
|
|
44
|
+
|
|
45
|
+
response_props = page_props.merge(
|
|
46
|
+
kagero: {
|
|
47
|
+
component: component,
|
|
48
|
+
html: body_html,
|
|
49
|
+
title: page_instance.kagero_title
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
if inertia_request?
|
|
54
|
+
render_kagero_json(component, response_props, url)
|
|
55
|
+
else
|
|
56
|
+
render_kagero_shell(component, response_props, url)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def render_kagero_json(component, props, url)
|
|
61
|
+
content_type("application/json; charset=utf-8")
|
|
62
|
+
headers("X-Inertia" => "true", "Vary" => "X-Inertia")
|
|
63
|
+
render_kagero_page_hash(component, props, url).to_json
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def render_kagero_shell(component, props, url)
|
|
67
|
+
page_hash = render_kagero_page_hash(component, props, url)
|
|
68
|
+
page_json = ::Rack::Utils.escape_html(page_hash.to_json)
|
|
69
|
+
html = <<~HTML
|
|
70
|
+
<!doctype html>
|
|
71
|
+
<html lang="ja">
|
|
72
|
+
<head>
|
|
73
|
+
<meta charset="utf-8">
|
|
74
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
75
|
+
<title>#{::Rack::Utils.escape_html(props.dig(:kagero, :title).to_s)}</title>
|
|
76
|
+
<script type="module">#{Sinatra::Kagero::Runtime::SOURCE}</script>
|
|
77
|
+
</head>
|
|
78
|
+
<body>
|
|
79
|
+
<main id="#{::Rack::Utils.escape_html(settings.kagero_root_id)}" data-kagero-root data-page="#{page_json}">#{props.dig(:kagero, :html)}</main>
|
|
80
|
+
</body>
|
|
81
|
+
</html>
|
|
82
|
+
HTML
|
|
83
|
+
|
|
84
|
+
content_type("text/html; charset=utf-8")
|
|
85
|
+
html
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def render_kagero_page_hash(component, props, url)
|
|
89
|
+
response_obj = Sinatra::Inertia::Response.new(
|
|
90
|
+
component: component,
|
|
91
|
+
props: props,
|
|
92
|
+
request: request,
|
|
93
|
+
version: current_inertia_version,
|
|
94
|
+
url: url,
|
|
95
|
+
encrypt_history: false,
|
|
96
|
+
clear_history: false,
|
|
97
|
+
shared: current_inertia_shared,
|
|
98
|
+
errors: inertia_errors_payload
|
|
99
|
+
)
|
|
100
|
+
page_hash = response_obj.to_h
|
|
101
|
+
sweep_inertia_session!
|
|
102
|
+
page_hash
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
register Kagero if respond_to?(:register)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
::Kagero = Sinatra::Kagero unless defined?(::Kagero)
|
data/runtime/kagero.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
const root = document.querySelector("[data-kagero-root]");
|
|
2
|
+
|
|
3
|
+
let currentPage = null;
|
|
4
|
+
let currentScroll = { left: 0, top: 0 };
|
|
5
|
+
|
|
6
|
+
function readInitialPage() {
|
|
7
|
+
if (!root) return null;
|
|
8
|
+
const raw = root.getAttribute("data-page");
|
|
9
|
+
if (!raw) return null;
|
|
10
|
+
return JSON.parse(raw);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function pageHtml(page) {
|
|
14
|
+
return page && page.props && page.props.kagero && page.props.kagero.html;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function rememberScroll() {
|
|
18
|
+
currentScroll = { left: window.scrollX, top: window.scrollY };
|
|
19
|
+
if (history.state && history.state.kagero) {
|
|
20
|
+
history.replaceState({ ...history.state, scroll: currentScroll }, "", location.href);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function applyPage(page, { replace = false, preserveScroll = false } = {}) {
|
|
25
|
+
if (!root) return;
|
|
26
|
+
const html = pageHtml(page);
|
|
27
|
+
if (typeof html === "string") root.innerHTML = html;
|
|
28
|
+
root.setAttribute("data-page", JSON.stringify(page));
|
|
29
|
+
currentPage = page;
|
|
30
|
+
|
|
31
|
+
const state = { kagero: true, page, scroll: preserveScroll ? currentScroll : { left: 0, top: 0 } };
|
|
32
|
+
if (replace) history.replaceState(state, "", page.url);
|
|
33
|
+
else history.pushState(state, "", page.url);
|
|
34
|
+
|
|
35
|
+
if (!preserveScroll) window.scrollTo(0, 0);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function visit(url, options = {}) {
|
|
39
|
+
rememberScroll();
|
|
40
|
+
const headers = new Headers(options.headers || {});
|
|
41
|
+
headers.set("X-Inertia", "true");
|
|
42
|
+
headers.set("X-Inertia-Version", currentPage ? currentPage.version : "");
|
|
43
|
+
headers.set("X-Requested-With", "XMLHttpRequest");
|
|
44
|
+
headers.set("Accept", "application/json, text/html;q=0.9");
|
|
45
|
+
|
|
46
|
+
const response = await fetch(url, {
|
|
47
|
+
method: options.method || "GET",
|
|
48
|
+
body: options.body,
|
|
49
|
+
headers,
|
|
50
|
+
credentials: "same-origin",
|
|
51
|
+
redirect: "follow"
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (response.status === 409 && response.headers.get("X-Inertia-Location")) {
|
|
55
|
+
location.href = response.headers.get("X-Inertia-Location");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!response.ok) throw new Error(`Kagero visit failed: ${response.status}`);
|
|
60
|
+
|
|
61
|
+
const page = await response.json();
|
|
62
|
+
applyPage(page, {
|
|
63
|
+
replace: options.replace === true,
|
|
64
|
+
preserveScroll: options.preserveScroll === true
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function formBody(form) {
|
|
69
|
+
const method = (form.getAttribute("method") || "GET").toUpperCase();
|
|
70
|
+
if (method === "GET") return null;
|
|
71
|
+
return new FormData(form);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function formUrl(form) {
|
|
75
|
+
const action = form.getAttribute("action") || location.href;
|
|
76
|
+
const method = (form.getAttribute("method") || "GET").toUpperCase();
|
|
77
|
+
if (method !== "GET") return action;
|
|
78
|
+
|
|
79
|
+
const url = new URL(action, location.href);
|
|
80
|
+
const data = new FormData(form);
|
|
81
|
+
for (const [key, value] of data.entries()) url.searchParams.set(key, value);
|
|
82
|
+
return url.toString();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
document.addEventListener("click", (event) => {
|
|
86
|
+
const link = event.target.closest("a[data-kagero]");
|
|
87
|
+
if (link) {
|
|
88
|
+
event.preventDefault();
|
|
89
|
+
visit(link.href, {
|
|
90
|
+
replace: link.dataset.kageroReplace === "true",
|
|
91
|
+
preserveScroll: link.dataset.kageroPreserveScroll === "true"
|
|
92
|
+
});
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const reload = event.target.closest("[data-kagero-reload]");
|
|
97
|
+
if (reload) {
|
|
98
|
+
event.preventDefault();
|
|
99
|
+
const only = reload.dataset.kageroOnly || "";
|
|
100
|
+
const headers = {};
|
|
101
|
+
if (currentPage && only) {
|
|
102
|
+
headers["X-Inertia-Partial-Component"] = currentPage.component;
|
|
103
|
+
headers["X-Inertia-Partial-Data"] = only;
|
|
104
|
+
}
|
|
105
|
+
visit(location.href, { replace: true, preserveScroll: true, headers });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
document.addEventListener("submit", (event) => {
|
|
110
|
+
const form = event.target.closest("form[data-kagero]");
|
|
111
|
+
if (!form) return;
|
|
112
|
+
|
|
113
|
+
event.preventDefault();
|
|
114
|
+
visit(formUrl(form), {
|
|
115
|
+
method: (form.getAttribute("method") || "GET").toUpperCase(),
|
|
116
|
+
body: formBody(form),
|
|
117
|
+
preserveScroll: form.dataset.kageroPreserveScroll === "true"
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
window.addEventListener("popstate", (event) => {
|
|
122
|
+
if (event.state && event.state.kagero && event.state.page) {
|
|
123
|
+
currentPage = event.state.page;
|
|
124
|
+
const html = pageHtml(currentPage);
|
|
125
|
+
if (typeof html === "string") root.innerHTML = html;
|
|
126
|
+
const scroll = event.state.scroll || { left: 0, top: 0 };
|
|
127
|
+
window.scrollTo(scroll.left, scroll.top);
|
|
128
|
+
} else {
|
|
129
|
+
location.reload();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
currentPage = readInitialPage();
|
|
134
|
+
if (currentPage) {
|
|
135
|
+
history.replaceState({ kagero: true, page: currentPage, scroll: { left: 0, top: 0 } }, "", currentPage.url);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
window.Kagero = { visit };
|
metadata
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: sinatra-kagero
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Kazuhiro Homma
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-06-25 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: sinatra-inertia
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '0.1'
|
|
20
|
+
- - "<"
|
|
21
|
+
- !ruby/object:Gem::Version
|
|
22
|
+
version: '2.0'
|
|
23
|
+
type: :runtime
|
|
24
|
+
prerelease: false
|
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
26
|
+
requirements:
|
|
27
|
+
- - ">="
|
|
28
|
+
- !ruby/object:Gem::Version
|
|
29
|
+
version: '0.1'
|
|
30
|
+
- - "<"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.0'
|
|
33
|
+
- !ruby/object:Gem::Dependency
|
|
34
|
+
name: phlex
|
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '2.4'
|
|
40
|
+
- - "<"
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: '3.0'
|
|
43
|
+
type: :runtime
|
|
44
|
+
prerelease: false
|
|
45
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
46
|
+
requirements:
|
|
47
|
+
- - ">="
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: '2.4'
|
|
50
|
+
- - "<"
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '3.0'
|
|
53
|
+
- !ruby/object:Gem::Dependency
|
|
54
|
+
name: literal
|
|
55
|
+
requirement: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - ">="
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '1.0'
|
|
60
|
+
- - "<"
|
|
61
|
+
- !ruby/object:Gem::Version
|
|
62
|
+
version: '2.0'
|
|
63
|
+
type: :runtime
|
|
64
|
+
prerelease: false
|
|
65
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
66
|
+
requirements:
|
|
67
|
+
- - ">="
|
|
68
|
+
- !ruby/object:Gem::Version
|
|
69
|
+
version: '1.0'
|
|
70
|
+
- - "<"
|
|
71
|
+
- !ruby/object:Gem::Version
|
|
72
|
+
version: '2.0'
|
|
73
|
+
description: |
|
|
74
|
+
Kagero is a Ruby-first application layer on top of sinatra-inertia:
|
|
75
|
+
Phlex page classes, Literal-style props schemas, Ruby form/command
|
|
76
|
+
validation, and a hidden browser runtime for SPA-like navigation without
|
|
77
|
+
exposing JavaScript as the primary userland authoring model.
|
|
78
|
+
email:
|
|
79
|
+
executables: []
|
|
80
|
+
extensions: []
|
|
81
|
+
extra_rdoc_files: []
|
|
82
|
+
files:
|
|
83
|
+
- CHANGELOG.md
|
|
84
|
+
- LICENSE
|
|
85
|
+
- README.md
|
|
86
|
+
- lib/sinatra/kagero.rb
|
|
87
|
+
- lib/sinatra/kagero/command.rb
|
|
88
|
+
- lib/sinatra/kagero/page.rb
|
|
89
|
+
- lib/sinatra/kagero/props.rb
|
|
90
|
+
- lib/sinatra/kagero/runtime.rb
|
|
91
|
+
- lib/sinatra/kagero/version.rb
|
|
92
|
+
- runtime/kagero.js
|
|
93
|
+
homepage: https://github.com/kazuph/homura
|
|
94
|
+
licenses:
|
|
95
|
+
- MIT
|
|
96
|
+
metadata:
|
|
97
|
+
homepage_uri: https://github.com/kazuph/homura
|
|
98
|
+
source_code_uri: https://github.com/kazuph/homura/tree/main/gems/sinatra-kagero
|
|
99
|
+
bug_tracker_uri: https://github.com/kazuph/homura/issues
|
|
100
|
+
changelog_uri: https://github.com/kazuph/homura/blob/main/gems/sinatra-kagero/CHANGELOG.md
|
|
101
|
+
readme_uri: https://github.com/kazuph/homura/blob/main/gems/sinatra-kagero/README.md
|
|
102
|
+
homura.auto_await: 'true'
|
|
103
|
+
post_install_message:
|
|
104
|
+
rdoc_options: []
|
|
105
|
+
require_paths:
|
|
106
|
+
- lib
|
|
107
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
108
|
+
requirements:
|
|
109
|
+
- - ">="
|
|
110
|
+
- !ruby/object:Gem::Version
|
|
111
|
+
version: 3.1.0
|
|
112
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
113
|
+
requirements:
|
|
114
|
+
- - ">="
|
|
115
|
+
- !ruby/object:Gem::Version
|
|
116
|
+
version: '0'
|
|
117
|
+
requirements: []
|
|
118
|
+
rubygems_version: 3.0.3.1
|
|
119
|
+
signing_key:
|
|
120
|
+
specification_version: 4
|
|
121
|
+
summary: Ruby-way Inertia experience for Sinatra and Homura
|
|
122
|
+
test_files: []
|