generated from avior/template-web-astro
392
src/libs/Hyperion.ts
Normal file
392
src/libs/Hyperion.ts
Normal 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
|
||||
}
|
||||
}
|
@ -1,3 +1,90 @@
|
||||
# Libs
|
||||
|
||||
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>
|
||||
```
|
||||
|
10
src/pages/api/v1/checkout.ts
Normal file
10
src/pages/api/v1/checkout.ts
Normal 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()
|
||||
}
|
21
src/pages/api/v1/projects/index.ts
Normal file
21
src/pages/api/v1/projects/index.ts
Normal 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()
|
||||
}
|
@ -1,4 +1,6 @@
|
||||
---
|
||||
import Button from 'components/global/Button.astro'
|
||||
import Input from 'components/global/Input.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">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<script>
|
||||
import Hyperion from 'libs/Hyperion'
|
||||
|
||||
Hyperion.setup()
|
||||
</script>
|
||||
|
Reference in New Issue
Block a user