elevhyra 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/LICENSE.txt +21 -0
- data/README.md +78 -0
- data/exe/elevhyra +33 -0
- data/lib/elevhyra/version.rb +4 -0
- data/lib/elevhyra.rb +829 -0
- metadata +48 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 21be8707b702866408f2852f54a10933a7445815a508d79581256b26687e2db3
|
|
4
|
+
data.tar.gz: 9f1b4cf71d0905f9d905c776c62b45e2169a67d5f29d4d4374e4f6b3384fb1ec
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f0905453498d4fd877649b7f86a4d53ffee53f17bdc0354932499ecf2194671b44aed3cff4e9579851884cb82b9d79ddf53b5343265f1aec0841d956ec2c228f
|
|
7
|
+
data.tar.gz: 2f42b3b933f19227204825caa035b8774857bff74e3db64d3e1c76ab3c178266448697c39367ed00b65fba7113a399f3b153a3fa3efec013e43864611fb361ac
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Elevhyra Demjhon Silver
|
|
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
|
|
13
|
+
all 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
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Elevhyra
|
|
2
|
+
|
|
3
|
+
[](https://github.com/demjhonsilver/elevhyra/actions/workflows/ci.yml)
|
|
4
|
+
[](https://badge.fury.io/rb/elevhyra)
|
|
5
|
+
|
|
6
|
+
## Introduction
|
|
7
|
+
|
|
8
|
+
Elevhyra is a reactive frontend framework inspired by Vue.js, purpose-built for Hyraft applications. It adds signals, routing, and real-time updates to your Ruby backend – enabling modern, reactive UIs without leaving the Hyraft ecosystem.
|
|
9
|
+
|
|
10
|
+
## Tagline
|
|
11
|
+
|
|
12
|
+
Elevhyra ( Elevating Hyraft )
|
|
13
|
+
|
|
14
|
+
Elevhyra - Elevating Hyraft with reactive JavaScript
|
|
15
|
+
|
|
16
|
+
# Elevhyra
|
|
17
|
+
|
|
18
|
+
**Elevate your Hyraft applications with reactive JavaScript**
|
|
19
|
+
|
|
20
|
+
Elevhyra is the reactive frontend framework designed specifically to
|
|
21
|
+
enhance Hyraft applications, adding signals, routing, and real-time
|
|
22
|
+
updates to your Ruby backend.
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
## Features
|
|
26
|
+
|
|
27
|
+
| Feature | Description |
|
|
28
|
+
|---------|-------------|
|
|
29
|
+
| ⚡ **Signals** | Fine-grained reactive state management |
|
|
30
|
+
| 🧭 **Routing** | Client-side routing with nested views and route guards |
|
|
31
|
+
| 🔄 **Real-time Updates** | Automatic UI synchronization with backend changes |
|
|
32
|
+
| 🧩 **Component System** | Reusable, composable UI components |
|
|
33
|
+
| 🔌 **Hyraft Integration** | First-class support for hexagonal architecture |
|
|
34
|
+
| 📦 **Zero Config** | Works out of the box with Hyraft applications |
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
gem install elevhyra
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
|
|
45
|
+
|
|
46
|
+
Install the gem and add to the application's Gemfile by executing:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
If bundler is not being used to manage dependencies, install the gem by executing:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Usage
|
|
59
|
+
|
|
60
|
+
TODO: Write usage instructions here
|
|
61
|
+
|
|
62
|
+
## Development
|
|
63
|
+
|
|
64
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
65
|
+
|
|
66
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
|
67
|
+
|
|
68
|
+
## Contributing
|
|
69
|
+
|
|
70
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/demjhonsilver/elevhyra. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/demjhonsilver/elevhyra/blob/master/CODE_OF_CONDUCT.md).
|
|
71
|
+
|
|
72
|
+
## License
|
|
73
|
+
|
|
74
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
75
|
+
|
|
76
|
+
## Code of Conduct
|
|
77
|
+
|
|
78
|
+
Everyone interacting in the Elevhyra project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/elevhyra/blob/master/CODE_OF_CONDUCT.md).
|
data/exe/elevhyra
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
require_relative "../lib/elevhyra"
|
|
4
|
+
|
|
5
|
+
case ARGV.first
|
|
6
|
+
when "version", "-v", "--version"
|
|
7
|
+
puts Elevhyra::VERSION
|
|
8
|
+
when "info"
|
|
9
|
+
puts "Elevhyra Framework v#{Elevhyra::VERSION}"
|
|
10
|
+
puts "Status: Active"
|
|
11
|
+
puts "Type: JavaScript Framework (generated at runtime)"
|
|
12
|
+
puts "Size: #{Elevhyra.js_content.bytesize} bytes"
|
|
13
|
+
puts "Integration: Hyraft compatible"
|
|
14
|
+
when "js"
|
|
15
|
+
# Output the generated JavaScript directly
|
|
16
|
+
print Elevhyra.js_content
|
|
17
|
+
when "help", "-h", "--help"
|
|
18
|
+
puts "Elevhyra v#{Elevhyra::VERSION}"
|
|
19
|
+
puts ""
|
|
20
|
+
puts "Commands:"
|
|
21
|
+
puts " version, -v, --version Show version number"
|
|
22
|
+
puts " info Show framework information"
|
|
23
|
+
puts " js Output generated JavaScript to stdout"
|
|
24
|
+
puts " help, -h, --help Show this help message"
|
|
25
|
+
puts ""
|
|
26
|
+
puts "Examples:"
|
|
27
|
+
puts " elevhyra info"
|
|
28
|
+
puts " elevhyra js > elevhyra.js # Save generated JS to file"
|
|
29
|
+
puts " elevhyra version"
|
|
30
|
+
else
|
|
31
|
+
puts "Elevhyra v#{Elevhyra::VERSION}"
|
|
32
|
+
puts "Run 'elevhyra help' for usage"
|
|
33
|
+
end
|
data/lib/elevhyra.rb
ADDED
|
@@ -0,0 +1,829 @@
|
|
|
1
|
+
require_relative "elevhyra/version"
|
|
2
|
+
|
|
3
|
+
module Elevhyra
|
|
4
|
+
def self.js_content
|
|
5
|
+
@js_content ||= generate_js_content
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def self.generate_js_content
|
|
9
|
+
<<~JAVASCRIPT
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// ELEVHYRA FRAMEWORK v1.0 - ES2025 Modular Architecture
|
|
12
|
+
// Auto-generated at runtime: #{Time.now}
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// CORE - Private Symbols (WeakMap-based private storage)
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
const _signals = new WeakMap()
|
|
20
|
+
const _watchers = new WeakMap()
|
|
21
|
+
const _value = new WeakMap()
|
|
22
|
+
const _routes = new WeakMap()
|
|
23
|
+
const _container = new WeakMap()
|
|
24
|
+
const _params = new WeakMap()
|
|
25
|
+
const _routeMap = new WeakMap()
|
|
26
|
+
|
|
27
|
+
// ============================================================================
|
|
28
|
+
// MODULE 1: DOM Helpers Module
|
|
29
|
+
// ============================================================================
|
|
30
|
+
|
|
31
|
+
const DOMHelpers = (() => {
|
|
32
|
+
const pickElement = (id) => document.getElementById(id)
|
|
33
|
+
const pickSelector = (selector) => document.querySelector(selector)
|
|
34
|
+
const pickSelectorAll = (selector) => document.querySelectorAll(selector)
|
|
35
|
+
|
|
36
|
+
const setSelector = (selector, html) => {
|
|
37
|
+
const element = document.getElementById(selector) || document.querySelector(selector)
|
|
38
|
+
if (element) element.innerHTML = html
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const setEachSelector = (selector, html) => {
|
|
42
|
+
document.querySelectorAll(selector).forEach(el => el.innerHTML = html)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const fixTrailingSlash = () => {
|
|
46
|
+
const path = globalThis.location.pathname
|
|
47
|
+
if (path !== '/' && path.endsWith('/')) {
|
|
48
|
+
const newPath = path.slice(0, -1) + globalThis.location.search + globalThis.location.hash
|
|
49
|
+
globalThis.location.replace(newPath)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
pickElement, pickSelector, pickSelectorAll,
|
|
55
|
+
setSelector, setEachSelector, fixTrailingSlash
|
|
56
|
+
}
|
|
57
|
+
})()
|
|
58
|
+
|
|
59
|
+
// ============================================================================
|
|
60
|
+
// MODULE 2: Signal System (Reactive State)
|
|
61
|
+
// ============================================================================
|
|
62
|
+
|
|
63
|
+
const SignalModule = (() => {
|
|
64
|
+
const signalStore = new Map()
|
|
65
|
+
const cacheStore = new Map()
|
|
66
|
+
|
|
67
|
+
const sanitizeHtml = (str) => {
|
|
68
|
+
if (!str || typeof str !== 'string') return str
|
|
69
|
+
const div = document.createElement('div')
|
|
70
|
+
div.textContent = str
|
|
71
|
+
return div.innerHTML
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const updateDOM = (name, value, sanitize = true) => {
|
|
75
|
+
let cached = cacheStore.get(name)
|
|
76
|
+
|
|
77
|
+
if (!cached) {
|
|
78
|
+
const elements = document.querySelectorAll(`[signal="${name}"]`)
|
|
79
|
+
const property = elements[0]?.getAttribute('property') || 'text'
|
|
80
|
+
cached = { elements, property }
|
|
81
|
+
cacheStore.set(name, cached)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const { elements, property } = cached
|
|
85
|
+
const finalValue = (property === 'html' && sanitize) ? sanitizeHtml(value) : value
|
|
86
|
+
|
|
87
|
+
for (let i = 0; i < elements.length; i++) {
|
|
88
|
+
if (property === 'text') elements[i].textContent = finalValue
|
|
89
|
+
else if (property === 'html') elements[i].innerHTML = finalValue
|
|
90
|
+
else if (property === 'value') elements[i].value = finalValue
|
|
91
|
+
else elements[i][property] = finalValue
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const createSignal = (name, initialValue = null) => {
|
|
96
|
+
if (signalStore.has(name)) return signalStore.get(name)
|
|
97
|
+
|
|
98
|
+
let currentValue = initialValue
|
|
99
|
+
const watchers = new Set()
|
|
100
|
+
|
|
101
|
+
updateDOM(name, initialValue, false)
|
|
102
|
+
|
|
103
|
+
const signal = {
|
|
104
|
+
get value() { return currentValue },
|
|
105
|
+
|
|
106
|
+
set value(newValue) {
|
|
107
|
+
if (Object.is(currentValue, newValue)) return
|
|
108
|
+
currentValue = newValue
|
|
109
|
+
updateDOM(name, newValue)
|
|
110
|
+
watchers.forEach(cb => { try { cb(newValue) } catch(e) { console.error(e) } })
|
|
111
|
+
if (globalThis.displayIf) globalThis.displayIf(document.body)
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
watch: (callback) => {
|
|
115
|
+
watchers.add(callback)
|
|
116
|
+
return () => watchers.delete(callback)
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
dispose: () => {
|
|
120
|
+
watchers.clear()
|
|
121
|
+
signalStore.delete(name)
|
|
122
|
+
cacheStore.delete(name)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
signalStore.set(name, signal)
|
|
127
|
+
return signal
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const getSignal = (name) => signalStore.get(name)
|
|
131
|
+
const getAllSignals = () => Array.from(signalStore.keys())
|
|
132
|
+
|
|
133
|
+
return { Signal: createSignal, getSignal, getAllSignals }
|
|
134
|
+
})()
|
|
135
|
+
|
|
136
|
+
// ============================================================================
|
|
137
|
+
// MODULE 3: Action System
|
|
138
|
+
// ============================================================================
|
|
139
|
+
|
|
140
|
+
const ActionModule = (() => {
|
|
141
|
+
const actionStore = new Map()
|
|
142
|
+
|
|
143
|
+
const registerAction = (name, handlers) => {
|
|
144
|
+
actionStore.set(name, handlers)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const setupActionListener = () => {
|
|
148
|
+
document.addEventListener('click', (event) => {
|
|
149
|
+
let element = event.target
|
|
150
|
+
while (element && element !== document.body) {
|
|
151
|
+
const actionAttr = element.getAttribute?.('action')
|
|
152
|
+
if (actionAttr) {
|
|
153
|
+
const dotIndex = actionAttr.indexOf('.')
|
|
154
|
+
if (dotIndex > -1) {
|
|
155
|
+
const signalName = actionAttr.slice(0, dotIndex)
|
|
156
|
+
const actionName = actionAttr.slice(dotIndex + 1)
|
|
157
|
+
const handler = actionStore.get(signalName)?.[actionName]
|
|
158
|
+
if (handler) handler()
|
|
159
|
+
}
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
element = element.parentElement
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return { Action: registerAction, setupActionListener }
|
|
168
|
+
})()
|
|
169
|
+
|
|
170
|
+
// ============================================================================
|
|
171
|
+
// MODULE 4: Router System
|
|
172
|
+
// ============================================================================
|
|
173
|
+
|
|
174
|
+
const RouterModule = (() => {
|
|
175
|
+
let routerInstance = null
|
|
176
|
+
let currentPath = ''
|
|
177
|
+
let historyStack = []
|
|
178
|
+
let historyIndex = -1
|
|
179
|
+
let templateCache = new Map()
|
|
180
|
+
|
|
181
|
+
class Router {
|
|
182
|
+
constructor(routes, { container } = {}) {
|
|
183
|
+
_routes.set(this, routes)
|
|
184
|
+
_container.set(this, typeof container === 'string'
|
|
185
|
+
? document.querySelector(container) || document.body
|
|
186
|
+
: container || document.body)
|
|
187
|
+
_params.set(this, {})
|
|
188
|
+
_routeMap.set(this, new Map(routes.map(r => [r.path, r])))
|
|
189
|
+
|
|
190
|
+
globalThis.addEventListener('popstate', () => this.#navigate(location.pathname, false))
|
|
191
|
+
this.#navigate(location.pathname, false)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
#matchRoute(path) {
|
|
195
|
+
const routes = _routes.get(this)
|
|
196
|
+
for (const route of routes) {
|
|
197
|
+
let pattern = route.path === '*' ? '.*' : route.path.replace(/:\\w+/g, '([^/]+)')
|
|
198
|
+
const match = path.match(new RegExp(`^${pattern}$`))
|
|
199
|
+
if (match) {
|
|
200
|
+
const paramNames = [...route.path.matchAll(/:\\w+/g)].map(m => m[0].slice(1))
|
|
201
|
+
const params = Object.fromEntries(paramNames.map((name, i) => [name, match[i + 1]]))
|
|
202
|
+
return { route, params }
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return null
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
#executeScripts(container) {
|
|
209
|
+
const scripts = container.querySelectorAll('script')
|
|
210
|
+
for (const oldScript of scripts) {
|
|
211
|
+
const newScript = document.createElement('script')
|
|
212
|
+
for (const attr of oldScript.attributes) {
|
|
213
|
+
newScript.setAttribute(attr.name, attr.value)
|
|
214
|
+
}
|
|
215
|
+
newScript.textContent = oldScript.textContent
|
|
216
|
+
oldScript.parentNode.replaceChild(newScript, oldScript)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
#reinitializeSignals() {
|
|
221
|
+
const signals = SignalModule.getAllSignals()
|
|
222
|
+
for (const name of signals) {
|
|
223
|
+
const signal = SignalModule.getSignal(name)
|
|
224
|
+
if (signal) {
|
|
225
|
+
const elements = document.querySelectorAll(`[signal="${name}"]`)
|
|
226
|
+
const property = elements[0]?.getAttribute('property') || 'text'
|
|
227
|
+
const value = signal.value
|
|
228
|
+
for (const el of elements) {
|
|
229
|
+
if (property === 'text') el.textContent = value
|
|
230
|
+
else if (property === 'html') el.innerHTML = value
|
|
231
|
+
else if (property === 'value') el.value = value
|
|
232
|
+
else el[property] = value
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
#bindLinks(container) {
|
|
239
|
+
const links = container.querySelectorAll('.elev-link')
|
|
240
|
+
for (const link of links) {
|
|
241
|
+
if (link.hasAttribute('data-router-bound')) continue
|
|
242
|
+
link.setAttribute('data-router-bound', 'true')
|
|
243
|
+
link.addEventListener('click', (e) => {
|
|
244
|
+
e.preventDefault()
|
|
245
|
+
const href = link.getAttribute('href')
|
|
246
|
+
if (href && !href.startsWith('http') && href !== '#') this.go(href)
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
#scanTemplates(container) {
|
|
252
|
+
container.querySelectorAll('[display]').forEach(el => {
|
|
253
|
+
const name = el.getAttribute('display')
|
|
254
|
+
if (name && !templateCache.has(name)) {
|
|
255
|
+
templateCache.set(name, el.innerHTML)
|
|
256
|
+
el.style.display = 'none'
|
|
257
|
+
}
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async #loadPage(route, params) {
|
|
262
|
+
_params.set(this, params)
|
|
263
|
+
try {
|
|
264
|
+
const res = await fetch(route.page)
|
|
265
|
+
const html = await res.text()
|
|
266
|
+
const container = _container.get(this)
|
|
267
|
+
container.innerHTML = html
|
|
268
|
+
this.#scanTemplates(container)
|
|
269
|
+
this.#executeScripts(container)
|
|
270
|
+
this.#reinitializeSignals()
|
|
271
|
+
this.#bindLinks(container)
|
|
272
|
+
if (globalThis.displayIf) globalThis.displayIf(container)
|
|
273
|
+
globalThis.dispatchEvent(new CustomEvent('route:change', {
|
|
274
|
+
detail: { path: currentPath, params, route }
|
|
275
|
+
}))
|
|
276
|
+
} catch (error) {
|
|
277
|
+
console.error('Failed to load page:', error)
|
|
278
|
+
_container.get(this).innerHTML = '<div class="alert alert-danger">Failed to load page</div>'
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async #navigate(path, addToHistory = true) {
|
|
283
|
+
if (path === currentPath) return
|
|
284
|
+
const match = this.#matchRoute(path)
|
|
285
|
+
if (!match) {
|
|
286
|
+
_container.get(this).innerHTML = '<div class="alert alert-danger">Page not found</div>'
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
currentPath = path
|
|
290
|
+
await this.#loadPage(match.route, match.params)
|
|
291
|
+
if (addToHistory) {
|
|
292
|
+
historyStack = [...historyStack.slice(0, historyIndex + 1), path]
|
|
293
|
+
historyIndex++
|
|
294
|
+
globalThis.history.pushState({}, '', path)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
go = (path) => path && this.#navigate(path, true)
|
|
299
|
+
back = () => historyIndex > 0 && this.#navigate(historyStack[--historyIndex], false)
|
|
300
|
+
forward = () => historyIndex < historyStack.length - 1 && this.#navigate(historyStack[++historyIndex], false)
|
|
301
|
+
reload = () => this.#navigate(currentPath, false)
|
|
302
|
+
get params() { return _params.get(this) }
|
|
303
|
+
get current() { return currentPath }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const createRouter = (routes, options) => {
|
|
307
|
+
routerInstance = new Router(routes, options)
|
|
308
|
+
return routerInstance
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const getRouter = () => routerInstance
|
|
312
|
+
|
|
313
|
+
const setupLinkHandler = () => {
|
|
314
|
+
document.addEventListener('click', (e) => {
|
|
315
|
+
const link = e.target.closest('.elev-link')
|
|
316
|
+
if (!link) return
|
|
317
|
+
const href = link.getAttribute('href')
|
|
318
|
+
if (!href || href.startsWith('http') || href === '#') return
|
|
319
|
+
e.preventDefault()
|
|
320
|
+
if (routerInstance) routerInstance.go(href)
|
|
321
|
+
})
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return { Router, createRouter, getRouter, setupLinkHandler, templateCache }
|
|
325
|
+
})()
|
|
326
|
+
|
|
327
|
+
// ============================================================================
|
|
328
|
+
// MODULE 5: API Client
|
|
329
|
+
// ============================================================================
|
|
330
|
+
|
|
331
|
+
const APIModule = (() => {
|
|
332
|
+
class APIClient {
|
|
333
|
+
#baseURL
|
|
334
|
+
#headers
|
|
335
|
+
#interceptors = { request: [], response: [] }
|
|
336
|
+
|
|
337
|
+
constructor(baseURL, options = {}) {
|
|
338
|
+
this.#baseURL = baseURL.replace(/\\/$/, '')
|
|
339
|
+
this.#headers = options.headers || { 'Content-Type': 'application/json' }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
useRequest(interceptor) { this.#interceptors.request.push(interceptor) }
|
|
343
|
+
useResponse(interceptor) { this.#interceptors.response.push(interceptor) }
|
|
344
|
+
|
|
345
|
+
#buildURL(endpoint, params = {}) {
|
|
346
|
+
const url = new URL(endpoint, this.#baseURL)
|
|
347
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
348
|
+
if (value !== undefined && value !== null) url.searchParams.append(key, value)
|
|
349
|
+
})
|
|
350
|
+
return url.toString()
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async #request(method, endpoint, data = null, params = {}) {
|
|
354
|
+
const url = this.#buildURL(endpoint, params)
|
|
355
|
+
let options = { method: method.toUpperCase(), headers: { ...this.#headers } }
|
|
356
|
+
if (data && ['POST', 'PUT', 'PATCH'].includes(method.toUpperCase())) {
|
|
357
|
+
options.body = JSON.stringify(data)
|
|
358
|
+
}
|
|
359
|
+
for (const interceptor of this.#interceptors.request) {
|
|
360
|
+
options = interceptor(options) || options
|
|
361
|
+
}
|
|
362
|
+
try {
|
|
363
|
+
const response = await fetch(url, options)
|
|
364
|
+
let result = await response.json()
|
|
365
|
+
for (const interceptor of this.#interceptors.response) {
|
|
366
|
+
result = interceptor(result, response) || result
|
|
367
|
+
}
|
|
368
|
+
return { data: result, status: response.status, statusText: response.statusText, headers: response.headers }
|
|
369
|
+
} catch (error) {
|
|
370
|
+
throw new Error(`API Error: ${error.message}`)
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
get = (endpoint, params) => this.#request('GET', endpoint, null, params)
|
|
375
|
+
post = (endpoint, data, params) => this.#request('POST', endpoint, data, params)
|
|
376
|
+
put = (endpoint, data, params) => this.#request('PUT', endpoint, data, params)
|
|
377
|
+
patch = (endpoint, data, params) => this.#request('PATCH', endpoint, data, params)
|
|
378
|
+
delete = (endpoint, params) => this.#request('DELETE', endpoint, null, params)
|
|
379
|
+
getById = (resource, id, params) => this.get(`/${resource}/${id}`, params)
|
|
380
|
+
create = (resource, data) => this.post(`/${resource}`, data)
|
|
381
|
+
update = (resource, id, data) => this.put(`/${resource}/${id}`, data)
|
|
382
|
+
patchUpdate = (resource, id, data) => this.patch(`/${resource}/${id}`, data)
|
|
383
|
+
remove = (resource, id) => this.delete(`/${resource}/${id}`)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const createAPI = (baseURL, options) => new APIClient(baseURL, options)
|
|
387
|
+
return { APIClient, createAPI }
|
|
388
|
+
})()
|
|
389
|
+
|
|
390
|
+
// ============================================================================
|
|
391
|
+
// MODULE 6: Template Displayer
|
|
392
|
+
// ============================================================================
|
|
393
|
+
|
|
394
|
+
const TemplateModule = (() => {
|
|
395
|
+
const getValueByPath = (obj, path) =>
|
|
396
|
+
path.split('.').reduce((current, key) => current?.[key], obj)
|
|
397
|
+
|
|
398
|
+
const displayer = (templateId, data) => {
|
|
399
|
+
const templateCache = RouterModule.templateCache
|
|
400
|
+
let template = templateCache.get(templateId)
|
|
401
|
+
if (!template) {
|
|
402
|
+
const templateEl = document.querySelector(`[display="${templateId}"]`)
|
|
403
|
+
if (!templateEl) {
|
|
404
|
+
console.error(`Template "${templateId}" not found`)
|
|
405
|
+
return ''
|
|
406
|
+
}
|
|
407
|
+
templateEl.style.display = 'none'
|
|
408
|
+
template = templateEl.innerHTML
|
|
409
|
+
templateCache.set(templateId, template)
|
|
410
|
+
}
|
|
411
|
+
return template.replace(/\\$\\[([\\w.]+)\\]/g, (match, path) => {
|
|
412
|
+
const value = getValueByPath(data, path)
|
|
413
|
+
return value !== undefined ? value : match
|
|
414
|
+
})
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const initTemplates = () => {
|
|
418
|
+
document.querySelectorAll('[display]').forEach(el => el.style.display = 'none')
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return { displayer, initTemplates, getValueByPath }
|
|
422
|
+
})()
|
|
423
|
+
|
|
424
|
+
// ============================================================================
|
|
425
|
+
// MODULE 7: URL/Path Helpers
|
|
426
|
+
// ============================================================================
|
|
427
|
+
|
|
428
|
+
const URLHelpers = (() => {
|
|
429
|
+
const getSegments = () => location.pathname.split('/').filter(s => s)
|
|
430
|
+
|
|
431
|
+
const pickPathSegment = (index) => getSegments()[index] || null
|
|
432
|
+
const pickCurrentPathSegment = (offset = 0) => {
|
|
433
|
+
const segments = getSegments()
|
|
434
|
+
return segments[segments.length - 1 + offset] || null
|
|
435
|
+
}
|
|
436
|
+
const pickPathParams = () => getSegments()
|
|
437
|
+
const pickQueryParam = (param) => new URLSearchParams(location.search).get(param)
|
|
438
|
+
const pickAllQueryParams = () => Object.fromEntries(new URLSearchParams(location.search))
|
|
439
|
+
const pickPath = () => location.pathname
|
|
440
|
+
const pickPathMatches = (pattern) => {
|
|
441
|
+
const path = location.pathname
|
|
442
|
+
if (pattern.includes(':id')) {
|
|
443
|
+
const regex = new RegExp('^' + pattern.replace(':id', '\\\\d+') + '$')
|
|
444
|
+
return regex.test(path)
|
|
445
|
+
}
|
|
446
|
+
return path === pattern
|
|
447
|
+
}
|
|
448
|
+
const pickExtractId = (pattern) => {
|
|
449
|
+
if (pattern.includes(':id')) {
|
|
450
|
+
const regex = new RegExp('^' + pattern.replace(':id', '(\\\\d+)') + '$')
|
|
451
|
+
const match = location.pathname.match(regex)
|
|
452
|
+
return match?.[1] || null
|
|
453
|
+
}
|
|
454
|
+
return null
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return {
|
|
458
|
+
pickPathSegment, pickCurrentPathSegment, pickPathParams,
|
|
459
|
+
pickQueryParam, pickAllQueryParams, pickPath,
|
|
460
|
+
pickPathMatches, pickExtractId
|
|
461
|
+
}
|
|
462
|
+
})()
|
|
463
|
+
|
|
464
|
+
// ============================================================================
|
|
465
|
+
// MODULE 8: Conditional Directives
|
|
466
|
+
// ============================================================================
|
|
467
|
+
|
|
468
|
+
const ConditionalModule = (() => {
|
|
469
|
+
const evaluateCondition = (condition) => {
|
|
470
|
+
if (!condition) return false
|
|
471
|
+
condition = condition.trim()
|
|
472
|
+
const directSignal = SignalModule.getSignal(condition)
|
|
473
|
+
if (directSignal !== undefined) {
|
|
474
|
+
const val = directSignal.value
|
|
475
|
+
if (typeof val === 'boolean') return val
|
|
476
|
+
if (typeof val === 'number') return val !== 0
|
|
477
|
+
if (typeof val === 'string') return val.length > 0 && val !== 'false'
|
|
478
|
+
if (Array.isArray(val)) return val.length > 0
|
|
479
|
+
if (typeof val === 'object') return val !== null && Object.keys(val).length > 0
|
|
480
|
+
return !!val
|
|
481
|
+
}
|
|
482
|
+
const lengthZeroMatch = condition.match(/(\w+)\.length\s*===\s*0/)
|
|
483
|
+
if (lengthZeroMatch) {
|
|
484
|
+
const signal = SignalModule.getSignal(lengthZeroMatch[1])
|
|
485
|
+
return signal?.value?.length === 0
|
|
486
|
+
}
|
|
487
|
+
const lengthPositiveMatch = condition.match(/(\w+)\.length\s*>\s*0/)
|
|
488
|
+
if (lengthPositiveMatch) {
|
|
489
|
+
const signal = SignalModule.getSignal(lengthPositiveMatch[1])
|
|
490
|
+
return signal?.value?.length > 0
|
|
491
|
+
}
|
|
492
|
+
const equalityMatch = condition.match(/(\w+)\s*===\s*(.+)/)
|
|
493
|
+
if (equalityMatch) {
|
|
494
|
+
const signalName = equalityMatch[1]
|
|
495
|
+
let rightValue = equalityMatch[2].trim()
|
|
496
|
+
const signal = SignalModule.getSignal(signalName)
|
|
497
|
+
if (signal) {
|
|
498
|
+
if (rightValue === 'true') rightValue = true
|
|
499
|
+
else if (rightValue === 'false') rightValue = false
|
|
500
|
+
else if (!isNaN(rightValue) && rightValue !== '') rightValue = parseFloat(rightValue)
|
|
501
|
+
else if (rightValue.startsWith('"') || rightValue.startsWith("'")) {
|
|
502
|
+
rightValue = rightValue.slice(1, -1)
|
|
503
|
+
}
|
|
504
|
+
return signal.value === rightValue
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
const signal = SignalModule.getSignal(condition)
|
|
508
|
+
if (signal) {
|
|
509
|
+
const val = signal.value
|
|
510
|
+
return Array.isArray(val) ? val.length > 0 : !!val
|
|
511
|
+
}
|
|
512
|
+
return false
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const displayIf = (container) => {
|
|
516
|
+
if (!container) return
|
|
517
|
+
const ifElements = container.querySelectorAll('[if]')
|
|
518
|
+
for (const ifEl of ifElements) {
|
|
519
|
+
const condition = ifEl.getAttribute('if')
|
|
520
|
+
const value = evaluateCondition(condition)
|
|
521
|
+
if (value) {
|
|
522
|
+
ifEl.style.display = ''
|
|
523
|
+
let next = ifEl.nextElementSibling
|
|
524
|
+
while (next?.hasAttribute && !next.hasAttribute('if')) {
|
|
525
|
+
if (next.hasAttribute('elseif') || next.hasAttribute('else')) {
|
|
526
|
+
next.style.display = 'none'
|
|
527
|
+
}
|
|
528
|
+
next = next.nextElementSibling
|
|
529
|
+
}
|
|
530
|
+
} else {
|
|
531
|
+
ifEl.style.display = 'none'
|
|
532
|
+
let next = ifEl.nextElementSibling
|
|
533
|
+
let found = false
|
|
534
|
+
while (next?.hasAttribute && !next.hasAttribute('if')) {
|
|
535
|
+
if (next.hasAttribute('elseif') && !found) {
|
|
536
|
+
const elseifCondition = next.getAttribute('elseif')
|
|
537
|
+
if (evaluateCondition(elseifCondition)) {
|
|
538
|
+
next.style.display = ''
|
|
539
|
+
found = true
|
|
540
|
+
} else {
|
|
541
|
+
next.style.display = 'none'
|
|
542
|
+
}
|
|
543
|
+
} else if (next.hasAttribute('else') && !found) {
|
|
544
|
+
next.style.display = ''
|
|
545
|
+
found = true
|
|
546
|
+
} else if (next.hasAttribute('elseif') || next.hasAttribute('else')) {
|
|
547
|
+
next.style.display = 'none'
|
|
548
|
+
}
|
|
549
|
+
next = next.nextElementSibling
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return { displayIf, evaluateCondition }
|
|
556
|
+
})()
|
|
557
|
+
|
|
558
|
+
// ============================================================================
|
|
559
|
+
// MODULE 9: Event Bus (Publish/Subscribe)
|
|
560
|
+
// ============================================================================
|
|
561
|
+
|
|
562
|
+
const EventBusModule = (() => {
|
|
563
|
+
const events = new Map()
|
|
564
|
+
|
|
565
|
+
const on = (eventName, callback) => {
|
|
566
|
+
if (!events.has(eventName)) events.set(eventName, new Set())
|
|
567
|
+
events.get(eventName).add(callback)
|
|
568
|
+
return () => events.get(eventName)?.delete(callback)
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const once = (eventName, callback) => {
|
|
572
|
+
const unsubscribe = on(eventName, (event) => {
|
|
573
|
+
callback(event)
|
|
574
|
+
unsubscribe()
|
|
575
|
+
})
|
|
576
|
+
return unsubscribe
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const off = (eventName, callback) => {
|
|
580
|
+
events.get(eventName)?.delete(callback)
|
|
581
|
+
if (events.get(eventName)?.size === 0) events.delete(eventName)
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const emit = (eventName, data = null) => {
|
|
585
|
+
const listeners = events.get(eventName)
|
|
586
|
+
if (!listeners) return false
|
|
587
|
+
const event = { name: eventName, data, timestamp: Date.now() }
|
|
588
|
+
for (const listener of listeners) {
|
|
589
|
+
try { listener(event) } catch(e) { console.error(e) }
|
|
590
|
+
}
|
|
591
|
+
return true
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const getEvents = () => [...events.keys()]
|
|
595
|
+
const listenerCount = (eventName) => events.get(eventName)?.size || 0
|
|
596
|
+
const clearEvents = () => events.clear()
|
|
597
|
+
|
|
598
|
+
return { on, once, off, emit, getEvents, listenerCount, clearEvents }
|
|
599
|
+
})()
|
|
600
|
+
|
|
601
|
+
// ============================================================================
|
|
602
|
+
// MODULE 10: Event Delegation System
|
|
603
|
+
// ============================================================================
|
|
604
|
+
|
|
605
|
+
const DelegationModule = (() => {
|
|
606
|
+
const delegations = new Map()
|
|
607
|
+
|
|
608
|
+
const delegate = (selector, eventType, handler) => {
|
|
609
|
+
if (!delegations.has(selector)) delegations.set(selector, new Map())
|
|
610
|
+
const eventMap = delegations.get(selector)
|
|
611
|
+
if (!eventMap.has(eventType)) eventMap.set(eventType, new Set())
|
|
612
|
+
eventMap.get(eventType).add(handler)
|
|
613
|
+
return () => {
|
|
614
|
+
eventMap.get(eventType)?.delete(handler)
|
|
615
|
+
if (eventMap.get(eventType)?.size === 0) eventMap.delete(eventType)
|
|
616
|
+
if (eventMap.size === 0) delegations.delete(selector)
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const live = (eventType, selector, handler) =>
|
|
621
|
+
delegate(selector, eventType, (e, target) => handler(e, target))
|
|
622
|
+
|
|
623
|
+
const handleDelegation = (event) => {
|
|
624
|
+
for (const [selector, eventMap] of delegations) {
|
|
625
|
+
const handlers = eventMap.get(event.type)
|
|
626
|
+
if (!handlers) continue
|
|
627
|
+
const target = event.target.closest(selector)
|
|
628
|
+
if (!target) continue
|
|
629
|
+
for (const handler of handlers) {
|
|
630
|
+
try { handler(event, target) } catch(e) { console.error(e) }
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const events = ['click', 'input', 'submit', 'change', 'keyup', 'keydown']
|
|
636
|
+
events.forEach(eventType => document.addEventListener(eventType, handleDelegation))
|
|
637
|
+
|
|
638
|
+
const clearDelegations = () => delegations.clear()
|
|
639
|
+
const getDelegations = () => {
|
|
640
|
+
const result = {}
|
|
641
|
+
for (const [selector, eventMap] of delegations) {
|
|
642
|
+
result[selector] = {}
|
|
643
|
+
for (const [eventType, handlers] of eventMap) {
|
|
644
|
+
result[selector][eventType] = handlers.size
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
return result
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
return { delegate, live, clearDelegations, getDelegations }
|
|
651
|
+
})()
|
|
652
|
+
|
|
653
|
+
// ============================================================================
|
|
654
|
+
// MODULE 11: FromEvent (Signal from DOM Events)
|
|
655
|
+
// ============================================================================
|
|
656
|
+
|
|
657
|
+
const FromEventModule = (() => {
|
|
658
|
+
const fromEventSignals = new Map()
|
|
659
|
+
|
|
660
|
+
const fromEvent = (target, eventType, signalName, transform = null) => {
|
|
661
|
+
if (fromEventSignals.has(signalName)) return fromEventSignals.get(signalName)
|
|
662
|
+
const element = typeof target === 'string' ? document.querySelector(target) : target
|
|
663
|
+
if (!element) {
|
|
664
|
+
console.error(`Element "${target}" not found`)
|
|
665
|
+
return null
|
|
666
|
+
}
|
|
667
|
+
const signal = SignalModule.Signal(signalName, null)
|
|
668
|
+
const handler = (event) => {
|
|
669
|
+
const value = transform ? transform(event) : event
|
|
670
|
+
signal.value = value
|
|
671
|
+
}
|
|
672
|
+
element.addEventListener(eventType, handler)
|
|
673
|
+
fromEventSignals.set(signalName, signal)
|
|
674
|
+
signal.dispose = () => {
|
|
675
|
+
element.removeEventListener(eventType, handler)
|
|
676
|
+
fromEventSignals.delete(signalName)
|
|
677
|
+
}
|
|
678
|
+
return signal
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
return { fromEvent }
|
|
682
|
+
})()
|
|
683
|
+
|
|
684
|
+
// ============================================================================
|
|
685
|
+
// MODULE 12: WaitFor (Promise-based event waiting)
|
|
686
|
+
// ============================================================================
|
|
687
|
+
|
|
688
|
+
const WaitForModule = (() => {
|
|
689
|
+
const waitForResolvers = new Map()
|
|
690
|
+
|
|
691
|
+
const waitFor = (eventName, timeout = null) => {
|
|
692
|
+
return new Promise((resolve, reject) => {
|
|
693
|
+
let timeoutId = null
|
|
694
|
+
if (timeout !== null) {
|
|
695
|
+
timeoutId = setTimeout(() => {
|
|
696
|
+
cleanup()
|
|
697
|
+
reject(new Error(`Timeout waiting for "${eventName}" after ${timeout}ms`))
|
|
698
|
+
}, timeout)
|
|
699
|
+
}
|
|
700
|
+
const resolver = (data) => {
|
|
701
|
+
cleanup()
|
|
702
|
+
resolve(data)
|
|
703
|
+
}
|
|
704
|
+
const cleanup = () => {
|
|
705
|
+
if (timeoutId) clearTimeout(timeoutId)
|
|
706
|
+
waitForResolvers.get(eventName)?.delete(resolver)
|
|
707
|
+
if (waitForResolvers.get(eventName)?.size === 0) waitForResolvers.delete(eventName)
|
|
708
|
+
}
|
|
709
|
+
if (!waitForResolvers.has(eventName)) waitForResolvers.set(eventName, new Set())
|
|
710
|
+
waitForResolvers.get(eventName).add(resolver)
|
|
711
|
+
})
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const resolveWaitFor = (eventName, data = null) => {
|
|
715
|
+
const resolvers = waitForResolvers.get(eventName)
|
|
716
|
+
if (resolvers?.size) {
|
|
717
|
+
resolvers.forEach(resolver => resolver(data))
|
|
718
|
+
waitForResolvers.delete(eventName)
|
|
719
|
+
return true
|
|
720
|
+
}
|
|
721
|
+
return false
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
return { waitFor, resolveWaitFor }
|
|
725
|
+
})()
|
|
726
|
+
|
|
727
|
+
// ============================================================================
|
|
728
|
+
// MODULE 13: TempState (Auto-clearing reactive state)
|
|
729
|
+
// ============================================================================
|
|
730
|
+
|
|
731
|
+
const TempStateModule = (() => {
|
|
732
|
+
const TempState = (initialState) => {
|
|
733
|
+
let original = structuredClone(initialState)
|
|
734
|
+
let current = structuredClone(initialState)
|
|
735
|
+
let subscribers = new Set()
|
|
736
|
+
|
|
737
|
+
const proxy = new Proxy(current, {
|
|
738
|
+
get(target, prop) {
|
|
739
|
+
const val = target[prop]
|
|
740
|
+
if (val && typeof val === 'object') return proxy
|
|
741
|
+
return val
|
|
742
|
+
},
|
|
743
|
+
set(target, prop, val) {
|
|
744
|
+
if (Object.is(target[prop], val)) return true
|
|
745
|
+
target[prop] = val
|
|
746
|
+
subscribers.forEach(cb => cb(prop, val))
|
|
747
|
+
return true
|
|
748
|
+
}
|
|
749
|
+
})
|
|
750
|
+
|
|
751
|
+
return {
|
|
752
|
+
state: proxy,
|
|
753
|
+
subscribe: (cb) => { subscribers.add(cb); return () => subscribers.delete(cb) },
|
|
754
|
+
reset: () => { current = structuredClone(original); subscribers.forEach(cb => cb('*reset*', current)) },
|
|
755
|
+
get: (path) => path.split('.').reduce((obj, key) => obj?.[key], current),
|
|
756
|
+
set: (path, val) => {
|
|
757
|
+
const parts = path.split('.')
|
|
758
|
+
const last = parts.pop()
|
|
759
|
+
const target = parts.reduce((obj, key) => obj[key] || (obj[key] = {}), current)
|
|
760
|
+
target[last] = val
|
|
761
|
+
subscribers.forEach(cb => cb(path, val))
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
return { TempState }
|
|
767
|
+
})()
|
|
768
|
+
|
|
769
|
+
// ============================================================================
|
|
770
|
+
// MODULE 14: Public API (Facade Pattern) - COMPLETE
|
|
771
|
+
// ============================================================================
|
|
772
|
+
|
|
773
|
+
const Elevhyra = (() => {
|
|
774
|
+
DOMHelpers.fixTrailingSlash()
|
|
775
|
+
ActionModule.setupActionListener()
|
|
776
|
+
RouterModule.setupLinkHandler()
|
|
777
|
+
|
|
778
|
+
if (document.readyState === 'loading') {
|
|
779
|
+
document.addEventListener('DOMContentLoaded', () => TemplateModule.initTemplates())
|
|
780
|
+
} else {
|
|
781
|
+
TemplateModule.initTemplates()
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
return {
|
|
785
|
+
...DOMHelpers,
|
|
786
|
+
Signal: SignalModule.Signal,
|
|
787
|
+
Action: ActionModule.Action,
|
|
788
|
+
createRouter: RouterModule.createRouter,
|
|
789
|
+
getRouter: RouterModule.getRouter,
|
|
790
|
+
createAPI: APIModule.createAPI,
|
|
791
|
+
displayer: TemplateModule.displayer,
|
|
792
|
+
...URLHelpers,
|
|
793
|
+
displayIf: ConditionalModule.displayIf,
|
|
794
|
+
tempState: TempStateModule.TempState,
|
|
795
|
+
...EventBusModule,
|
|
796
|
+
...DelegationModule,
|
|
797
|
+
fromEvent: FromEventModule.fromEvent,
|
|
798
|
+
waitFor: WaitForModule.waitFor,
|
|
799
|
+
resolveWaitFor: WaitForModule.resolveWaitFor
|
|
800
|
+
}
|
|
801
|
+
})()
|
|
802
|
+
|
|
803
|
+
globalThis.elev = Elevhyra
|
|
804
|
+
|
|
805
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
806
|
+
module.exports = Elevhyra
|
|
807
|
+
}
|
|
808
|
+
JAVASCRIPT
|
|
809
|
+
end
|
|
810
|
+
|
|
811
|
+
def self.register_with_hyraft
|
|
812
|
+
if defined?(Hyraft::Compiler::JavaScriptLibrary) &&
|
|
813
|
+
Hyraft::Compiler::JavaScriptLibrary.respond_to?(:register)
|
|
814
|
+
|
|
815
|
+
Hyraft::Compiler::JavaScriptLibrary.register('elevhyra', js_content)
|
|
816
|
+
puts "\e[1;31mElevhyra activated!\e[0m"
|
|
817
|
+
end
|
|
818
|
+
end
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
if defined?(Hyraft)
|
|
822
|
+
Elevhyra.register_with_hyraft
|
|
823
|
+
else
|
|
824
|
+
at_exit do
|
|
825
|
+
if defined?(Hyraft)
|
|
826
|
+
Elevhyra.register_with_hyraft
|
|
827
|
+
end
|
|
828
|
+
end
|
|
829
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: elevhyra
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Demjhon Silver
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: ElevHyra provides Signals, Actions, SPA Router, and API client for Hyraft
|
|
13
|
+
framework
|
|
14
|
+
executables:
|
|
15
|
+
- elevhyra
|
|
16
|
+
extensions: []
|
|
17
|
+
extra_rdoc_files: []
|
|
18
|
+
files:
|
|
19
|
+
- LICENSE.txt
|
|
20
|
+
- README.md
|
|
21
|
+
- exe/elevhyra
|
|
22
|
+
- lib/elevhyra.rb
|
|
23
|
+
- lib/elevhyra/version.rb
|
|
24
|
+
homepage: https://hyraft.com/elevhyra/docs
|
|
25
|
+
licenses:
|
|
26
|
+
- MIT
|
|
27
|
+
metadata:
|
|
28
|
+
homepage_uri: https://hyraft.com/elevhyra/docs
|
|
29
|
+
source_code_uri: https://github.com/hyraft/elevhyra
|
|
30
|
+
rubygems_mfa_required: 'true'
|
|
31
|
+
rdoc_options: []
|
|
32
|
+
require_paths:
|
|
33
|
+
- lib
|
|
34
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: 4.0.0
|
|
39
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
40
|
+
requirements:
|
|
41
|
+
- - ">="
|
|
42
|
+
- !ruby/object:Gem::Version
|
|
43
|
+
version: '0'
|
|
44
|
+
requirements: []
|
|
45
|
+
rubygems_version: 4.0.3
|
|
46
|
+
specification_version: 4
|
|
47
|
+
summary: Reactive JavaScript framework for Hyraft .hyr templates
|
|
48
|
+
test_files: []
|