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 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sinatra
4
+ module Kagero
5
+ VERSION = "1.0.0"
6
+ end
7
+ 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: []