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 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
+ [![CI](https://github.com/demjhonsilver/elevhyra/actions/workflows/ci.yml/badge.svg)](https://github.com/demjhonsilver/elevhyra/actions/workflows/ci.yml)
4
+ [![Gem Version](https://badge.fury.io/rb/elevhyra.svg)](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
@@ -0,0 +1,4 @@
1
+
2
+ module Elevhyra
3
+ VERSION = "1.0.0"
4
+ 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: []