feat: Add Hyperion

Signed-off-by: Avior <git@avior.me>
This commit is contained in:
2024-05-14 17:48:13 +02:00
parent b7c5f5148e
commit 9b2d412a9e
5 changed files with 563 additions and 0 deletions

392
src/libs/Hyperion.ts Normal file
View File

@ -0,0 +1,392 @@
import { objectLoop, objectSize } from '@dzeio/object-util'
/**
* Hyperion is a library that allow you to make changes to the HTML content by using a mix of `<template />` and `JSON` Reponse from API endpoints
*
* ex:
* ```html
* <button
* data-action="/api/v1/checkout" // the endpoint to use
* data-template="#pouet" // the template to use
* >Checkout</button>
* <template id="pouet"><p
* data-attribute="test" // the JSON path
* ></p></template>
* ```
* will place inside the <button> tag a <p> tag with the text of `test` from the JSON of the API endpoint
*
* this library is a mix between systems like React/Svelte (which have a lot of frontend handling) and HTMX (which return HTML entirely from the server)
*
* _Hyperion is a mix of HyperText(HTML) and Action_
*
* @author Florian Bouillon <contact@avior.me>
* @version 1.0.0
* @license MIT
*/
export default class Hyperion {
private static instance: Hyperion
private constructor() {
this.init()
}
/**
* setup the library for usage
*/
public static setup() {
if (!this.instance) {
this.instance = new Hyperion()
}
return this.instance
}
/**
* Allows you to manually trigger an element
* @param el the element to trigger
*/
public static trigger(el: HTMLElement) {
el.dispatchEvent(new Event('hyperion:trigger'))
}
/**
* initialise hyperion
* @param base the base to query from
*/
private init(base?: HTMLElement) {
(base ?? document).querySelectorAll<HTMLElement>('[data-action]').forEach((it) => {
this.initItem(it)
})
}
/**
* initialize a single element
* @param it the element to initialize
*/
private initItem(it: HTMLElement) {
// get the trigger action
let trigger = it.dataset.trigger ?? 'click'
// the triggering options
const options: {
once?: boolean
load?: boolean
after?: number
} = {}
// handle options
if (trigger.includes(' ')) {
const splitted = trigger.split(' ')
trigger = splitted.shift()!
for (const item of splitted) {
// item runs only once
if (item === 'once') {
options.once = true
continue
}
if (item === 'load') {
options.load = true
continue
}
if (item.includes(':')) {
const [key, value] = item.split(':', 2)
// trigger is done after {x}ms (if another trigger is not done)
if (key === 'after') {
options.after = parseInt(value!)
}
}
}
}
let timeout: NodeJS.Timeout | undefined
// the triggering function
let fn = () => {
if (options.after) { // handle options:after
if (timeout) {
clearTimeout(timeout)
timeout = undefined
}
timeout = setTimeout(() => {
this.processElement(it)
timeout = undefined
}, options.after)
} else {
this.processElement(it)
}
// handle event only happening once
if (options.once) {
it.removeEventListener('hyperion:trigger', fn)
it.removeEventListener(trigger, fn)
}
}
// event can also manually be triggered using `hyperion:trigger`
it.addEventListener('hyperion:trigger', fn)
it.addEventListener(trigger, fn)
// on load run instantly
if (options.load) {
this.processElement(it)
}
}
/**
* process an element that has it's trigger triggered
* @param it the element to process
*/
private async processElement(it: HTMLElement) {
const action = it.dataset.action // get the URL to try
const method = it.dataset.method ?? 'GET' // get the method to use
const subPath = it.dataset.path // get a subPath if desired (dot-separated values)
let target = it.dataset.target ?? 'innerHTML' // indicate how the data is set
const templateQuery = it.dataset.template // get the template query
const multiple = it.hasAttribute('data-multiple') // get if the result should be an array
const params: Record<string, any> = {} // the request parameters
/**
* PRE REQUEST HANDLING
*/
// handle mendatory elements that are not necessary set
if (!templateQuery || !action) {
it.dispatchEvent(new CustomEvent('hyperion:attributeError', {
detail: {
reason: 'data-template or data-action not found'
}
}))
throw new Error(`the data-template (${templateQuery}) or data-action (${action}) attribute is not set :(`)
}
const url = new URL(action, window.location.href)
// query the remote template
const template = document.querySelector<HTMLTemplateElement>(templateQuery)
if (!template) {
it.dispatchEvent(new CustomEvent('hyperion:templateError', {
detail: {
reason: 'template not found'
}
}))
throw new Error(`the Template for the query ${templateQuery} was not found ! :(`)
}
// pre-process the target attribute
let targetEl: HTMLElement | null = it
// handle if the target has a querySelector needed
if (target.includes(' ')) {
// split the target and the query
let [newTarget, query] = target.split(' ', 2)
// set the new target
target = newTarget!
// handle if the query start with `this`
let base: HTMLElement | Document = document
if (query?.startsWith('this')) {
query.replace('this', '')
base = it
}
// query the targetElement
targetEl = base.querySelector<HTMLElement>(query!)
}
// handle if the target was not found
if (!targetEl) {
template.dispatchEvent(new CustomEvent('hyperion:targetError', {
detail: {
type: 'target not found',
data: target
}
}))
throw new Error(`target not found ${target}`)
}
if (it.tagName === 'INPUT') {
const value = (it as HTMLInputElement).value
if (value) {
params[(it as HTMLInputElement).name] = (it as HTMLInputElement).value
}
} else if (it.tagName === 'FORM') {
const form = (it as HTMLFormElement)
for (const item of form.elements) {
const name = (item as HTMLInputElement).name
const value = (item as HTMLInputElement).value
if (value) {
params[name] = value
}
}
}
/**
* REQUEST HANDLING
*/
if (method === 'GET' && objectSize(params) > 0) {
objectLoop(params, (value, key) => {
url.searchParams.set(key, value)
})
}
// do the request
const res = await fetch(url, {
method: method,
body: method === 'POST' && objectSize(params) > 0 ? JSON.stringify(params) : null
})
/**
* POST REQUEST HANDLING
*/
// handle if the request does not succeed
if (res.status >= 400) {
it.dispatchEvent(new CustomEvent('hyperion:statusError', {
detail: {
statusCode: res.status
}
}))
throw new Error(`request returned a ${res.status} error code :(`)
}
// transform the response into JSON
let json = await res.json()
// if subPath was set get to the path described
if (subPath) {
for (const child of subPath.split('.')) {
json = json[child]
}
}
// create the template clone(s)
const clones: Array<HTMLElement> = []
// if the query should have multiple clones (data-multiple)
if (multiple) {
// the data should be an array :D
if (!Array.isArray(json)) {
it.dispatchEvent(new CustomEvent('hyperion:dataError', {
detail: {
type: 'data_not_array',
data: json
}
}))
throw new Error(`the data inputted should be an array, instead it is an ${typeof json}`)
}
// loop through the array
for (const item of json) {
// fill the template element with every values
clones.push(this.fillTemplate(template, item))
}
} else {
// fill the template element with the data
clones.push(this.fillTemplate(template, json))
}
// handle how the clones are placed
switch (target) {
case 'outerHTML': // the clones replace the targetted element
targetEl.replaceWith(...clones)
break;
case 'innerHTML': // the clones replace the childs of the targetted element
// remove the current childs
while (targetEl.firstChild) {
targetEl.removeChild(targetEl.firstChild)
}
// add each clones
clones.forEach((it) => targetEl.appendChild(it))
break
default:
break;
}
}
/**
* fill the template from the data given
*
* @param template the template to fill
* @param data the data to filel with
* @returns the filled HTMLElement
*/
private fillTemplate(template: HTMLTemplateElement, data: any): HTMLElement {
// clone the template
const clone = template.content.cloneNode(true) as HTMLElement
// go through every elements that has a attribute to fill
clone.querySelectorAll<HTMLElement>('[data-attribute]').forEach((it) => {
// get the raw attribute
const attrRaw = it.dataset.attribute!
// parse into an array
let attrs: Array<string>
if (attrRaw.includes(' ')) {
attrs = attrRaw.split(' ')
} else {
attrs = [attrRaw]
}
// loop through each attributes
for (let attr of attrs) {
// get the display type of the attribute
let target = it.dataset.type ?? 'text'
// detect if it should be set as an attribute instead
const setToAttribute = attr.includes(':')
if (setToAttribute) {
const splitted = attr.split(':', 2)
attr = splitted.pop()!
target = splitted[0]!
}
// handle processing of string
let content: string
// handle a string with attribute processing
if (attr.includes('{')) {
let res = /\{(.*?)\}/g.exec(attr)
while (res) {
attr = attr.replace(`${res[0]}`, data[res[1]!])
res = /\{(.*?)\}/g.exec(attr)
}
content = attr
} else { // handle basic string
content = attr === 'this' ? data : data[attr]
}
if (setToAttribute) { // set it as attribute
it.setAttribute(target, content)
} else {
// get the real target function
if (target === 'html') {
target = 'innerHTML'
} else if ( target === 'text') {
target = 'innerText'
}
if (!(target in it)) {
template.dispatchEvent(new CustomEvent('hyperion:dataError', {
detail: {
type: 'data-type invalid',
data: target
}
}))
throw new Error(`the data-type is not valid (${target}), it should be one of ('innerHTML' | 'innerText' | 'outerHTML' | 'outerText')`)
}
// set it has inner/outer HTML/Text
it[target as 'innerHTML' | 'innerText' | 'outerHTML' | 'outerText'] = content
}
}
// idk if necessary but remove the attributes from the final HTML
it.removeAttribute('data-attribute')
it.removeAttribute('data-type')
})
// setup the clone to work if it contains Hyperion markup
this.init(clone)
return clone
}
}

View File

@ -1,3 +1,90 @@
# Libs # Libs
Globally independent objects/classes/functions that SHOULD be unit testable by themselve Globally independent objects/classes/functions that SHOULD be unit testable by themselve
## Hyperion
Hyperion is a library that allows you to make change to the HTML content dynamically depending on the results of a JSON request
it is kinda a mix of React/Svelte and HTMX
it use both `data-` items and `<template />` elements
example:
```html
<!-- define your input -->
<button
data-action="/api/v1/checkout" // the endpoint to use
data-template="#pouet" // the template to use
>Checkout</button>
<!-- Define the template to fill -->
<template id="pouet"><p
data-attribute="test" // the JSON path
></p></template>
```
### Attributes
**on Item Attributes**
| nom | default | Type | Valeurs possible | Description |
| ------------- | --------- | ------- | ------------------------------- | -------------------------------------------------------- |
| data-trigger | click | Texte | Standard Event, load, after:xxx | see Triggers |
| data-action | | Texte | | the url to fetch |
| data-method | GET | Texte | GET, POST, PUT, DELETE | the method to use |
| data-path | | Texte | | the subpath to start from in the template |
| data-target | innerHTML | Texte | innerHTML, outerHTML | the target element, see Target |
| data-template | | Texte | CSS Query | the template CSS query |
| data-multiple | | Boolean | | if set, it will run on an array, else on everything else |
**In template Attributes**
| nom | default | Type | Valeurs possible | Description |
| -------------- | ------- | ---- | ---------------- | -------------------------------------------- |
| data-attribute | | Text | | the attribute to get from the fetched object |
| data-type | text | Text | | text, html, outerHTML, outerText |
### Triggers
To make a trigger you can define a WebStandard HTML Event like `click`, `mouseover`, etc and it will work after it is emitted by the browser.
there is also a special `load` trigger that will run hyperion on the tag after the page has loaded
you can have multiple triggers by adding them all with a space between (ex: `data-trigger="click, blur"`)
there is also modifiers:
- `once`: the request will run only once and never again after (note: `load` trigger don't count for this)
- `after:x`: Dedupplicate the request sent to the server by waiting at least `x` millis with not triggering done
### Targets
Targets defin where the generated content will be placed.
- `innerHTML`: it will be placed as a child of the current element
- `outerHTML`: it will replace the current element at the same position (making it runnable once)
You can also as a second argument change le location of the target by usin a CSS query
example:
```html
<button
...
data-target="innerHTML #pouet"
>Button</button>
<p id="pouet"></p>
```
Finally you can run the query from the current element you can prepend the query by `this`
example:
```html
<div
...
data-target="innerHTML this.pouet"
>
<p class="pouet"></p>
</div>
```

View File

@ -0,0 +1,10 @@
import type { APIRoute } from 'astro'
import ResponseBuilder from 'libs/ResponseBuilder'
function randomInt(min = 0, max = 10) {
return Math.round(Math.random() * max + min)
}
export const ALL: APIRoute = ({ url }) => {
return new ResponseBuilder().body({caca: 'pokemon', list: [url.searchParams.get('filter'), ...Array(randomInt(1, 20)).fill(0).map(() => randomInt(10, 100))]}).build()
}

View File

@ -0,0 +1,21 @@
import type { APIRoute } from 'astro'
import ResponseBuilder from 'libs/ResponseBuilder'
interface Project {
name: string
id: string
}
export const GET: APIRoute = ({ url }) => {
const nameFilter = url.searchParams.get('name')
return new ResponseBuilder().body(([
{
name: 'Holisané',
id: 'HOLIS'
},
{
name: 'Aptatio',
id: 'APTA'
}
] as Array<Project>).filter((it) => !nameFilter || it.name.includes(nameFilter))).build()
}

View File

@ -1,4 +1,6 @@
--- ---
import Button from 'components/global/Button.astro'
import Input from 'components/global/Input.astro'
import MainLayout from 'layouts/MainLayout.astro' import MainLayout from 'layouts/MainLayout.astro'
--- ---
@ -6,5 +8,56 @@ import MainLayout from 'layouts/MainLayout.astro'
<main class="container flex flex-col justify-center items-center h-screen gap-6"> <main class="container flex flex-col justify-center items-center h-screen gap-6">
<h1 class="text-8xl text-center">Dzeio Astro Template</h1> <h1 class="text-8xl text-center">Dzeio Astro Template</h1>
<h2 class="text-2xl text-center">Start editing src/pages/index.astro to see your changes!</h2> <h2 class="text-2xl text-center">Start editing src/pages/index.astro to see your changes!</h2>
<Button
data-action="/api/v1/checkout"
data-template="#pouet"
data-trigger="mouseenter after:100"
data-target="outerHTML"
>Checkout</Button>
<Input
name="name"
data-action="/api/v1/projects"
data-template="#projectItem"
data-trigger="keydown load after:100"
data-target="innerHTML ul"
data-multiple
/>
<p>Results</p>
<ul>
</ul>
</main> </main>
<template id="projectItem">
<li>
<a data-attribute="name href:/project/{id}"></a>
</li>
</template>
<!-- Define a template which contains how it is displayed -->
<template id="pouet">
<div>
<!-- Define the attribute in the object that will be assigned -->
<p data-attribute="caca"></p>
<!-- You can change the display type by selecting `html` | `innerHTML` | `outerHTML` | `text` | `innerText` | `outerText` -->
<p data-attribute="caca" data-type="html"></p>
<!-- You can even add another Requester inside the template! -->
<p
data-attribute="caca value:caca"
data-action="/api/v1/checkout"
data-template="#pouet"
data-trigger="mouseover"
data-target="outerHTML"
></p>
</div>
</template>
<template id="list">
<li data-attribute="this"></li>
</template>
</MainLayout> </MainLayout>
<script>
import Hyperion from 'libs/Hyperion'
Hyperion.setup()
</script>