cthulhu-rlyeh
Version:
DOM manipulation based on cthulhu node architecture
494 lines (376 loc) • 13.9 kB
JavaScript
import { Cthulhu } from 'cthulhu';
import { pascalOrCamelToKebab } from '@keltoi/naming-converting';
var tagBuffer = [];
const tagBufferGetAndPush = (prop='') =>{
if (!tagBuffer[prop]) tagBuffer[prop] = pascalOrCamelToKebab(prop);
return tagBuffer[prop]
};
const cloneByEntry = (obj) => {
const copy = {};
const isObjectOrValue = (v) => v instanceof Function
? v
: v instanceof Object
? cloneByEntry(v)
: v;
Object
.entries(obj)
.forEach(([key, value]) =>
copy[key] = value instanceof Array
? value.map(isObjectOrValue)
: isObjectOrValue(value)
);
return copy
};
const toMap = (o) => new Map(Object.entries(o));
class Doom extends Cthulhu{
#self;
#old;
#toRemove=false;
#root=false;
#rendered = false
get root(){return this.#root}
set root(value=false){this.#root = value;}
get isRendered(){ return this.#rendered } //if the element has been rendered
get isVirgin(){ return !this.#old } //if the element has never been built
get isDeleted(){ return this.#toRemove } //if the element has been deleted
constructor(me){
super(Doom,me,"content","attributes","events","styleProps","props","nsuri","ai");
}
static async $(tag='',me){
const doomInstance = await (new Doom(me)).build(tag);
doomInstance.root=true;
return doomInstance;
}
static template(me={}){
return Doom.$('template',me)
}
static async compare({
oldMap=new Map(),
newMap=new Map(),
set=(key,val)=>{},
remove=(key,val)=>{}
}){
newMap.forEach((val,key)=>{
if (oldMap.has(key)){
if (oldMap.get(key)!=val) set(key,val);
oldMap.delete(key);
} else set(key,val);
});
oldMap.forEach((val,key)=>remove(key,val));
}
#addAiComment = async (ai='',node = document.createElement())=>{
const comment = document
.createComment(`AI:::${ai}`);
node.appendChild(comment);
}
#setAttributes = async (node=document.createElement())=>{
const newMap = toMap(this.attributes);
if (this.#old?.has('attributes')){
const oldMap = toMap(this.#old.get('attributes'));
await Doom.compare({
oldMap,
newMap,
set:(key,val)=>node.setAttribute(pascalOrCamelToKebab(key),val),
remove:(key)=>{
if (key!='style') node.removeAttribute(pascalOrCamelToKebab(key));
}
});
}
else newMap.forEach((val,attr)=>node.setAttribute(pascalOrCamelToKebab(attr),val));
}
#setEvents= async(node=document.createElement())=>{
const newMap = toMap(this.events);
if (this.#old?.has('events')){
const oldMap = toMap(this.#old.get('events'));
await Doom.compare({
oldMap,
newMap,
set:(key,val)=>{
val.forEach(e=>node.addEventListener(key,e));
},
remove:(key,val)=>{
try{
val.forEach(e=>node.removeEventListener(key,e));
} finally {
return
}
}
});
}
else newMap.forEach((val,eve)=>val.forEach(e=>node.addEventListener(eve,e)));
}
#setStyle= async (node=document.createElement())=>{
const newMap = toMap(this.styleProps);
if (this.#old?.has('styleProps')){
const oldMap = toMap(this.#old.get('styleProps'));
await Doom.compare({
oldMap,
newMap,
set:(key,val)=>node.style[key]=val,
remove:(key)=>node.style[key]=''
});
}
else newMap.forEach((val,sty)=>node.style[sty]=val);
}
#setContent= async(node=document.createElement())=>{
if (this.#old?.has('content')){
if (this.#old.get('content')!==this.content){
node.innerHTML = this.content;
}
} else node.innerHTML = this.content;
}
#inner=(e, update = false)=>{
const self = new Map(Object.entries(this));
let children=[];
let structure = [];
let garbage = [];
self.forEach((element,prop)=>{
switch(prop){
case 'attributes':structure.push(this.#setAttributes(e));break;
case 'events':structure.push(this.#setEvents(e));break;
case 'styleProps':structure.push(this.#setStyle(e));break;
case 'content':structure.push(this.#setContent(e));break;
case 'nsuri':this.nsuri=element ;break;
case 'ai': this.#addAiComment(element,e);break;
case 'props': break;
default:{
const tag = tagBufferGetAndPush(prop);
if (element instanceof Array){
children = children
.concat(
element
.map((nest,i)=>{
if (!(nest instanceof Doom)) {
nest = new Doom(nest);
this[prop][i] = nest;
}
if (nest.isVirgin) return nest.build(tag)
if (update && !nest.isDeleted) return nest.build(null,true)
else if (nest.isDeleted) garbage.push(()=> {
nest.removeFrom(e);
this[prop].splice(i,1);
});
return nest
})
);
}
else if (element instanceof Doom) {
if (element.isVirgin)
element = element.build(
(element.root ?? false) ? null : tag
);
else if (update && !element.isDeleted) element = element.build(null,true);
else if (element.isDeleted) garbage.push(()=> {
element.removeFrom(e);
delete this[prop];
});
children.push(element);
} else {
const newOne = new Doom(element);
this[prop] = newOne;
children.push(newOne.build(tag));
}
}
}
});
return {children,structure,garbage}
}
delete(){
this.#rendered = false;
this.#toRemove = true;
}
#raiseElement(name){
if (!name) return this.#self
const element = this.nsuri
?document.createElementNS(this.nsuri,name)
:document.createElement(name);
return name==='template' ? element.content : element
}
async build(name='div'|null, update =false){
if (this.#toRemove) return this
const e = this.#raiseElement(name);
const {children, structure, garbage} = this.#inner(e, update);
await Promise.all(structure);
const childList = await Promise.all(children);
childList
.filter(child=>!child.isRendered)
.forEach(child=>child.renderOn(e));
garbage
.forEach(dispose=>dispose());
this.#self = e;
const oldBoy = cloneByEntry(this);
this.#old = new Map(Object.entries(oldBoy));
return this;
}
removeFrom(parent=document.createElement()){
if (parent.contains(this.#self)) parent.removeChild(this.#self);
}
renderOn(parent=document.createElement()){
parent.appendChild(this.#self);
this.#rendered = true;
}
appendChild(child = new Doom()){
child.renderOn(this.#self);
}
fire(event = new CustomEvent()){
this.#self.dispatchEvent(event);
}
focus(){
this.#self.focus();
}
blur(){
this.#self.blur();
}
}
const changing = (hook=()=>new Doom())=> hook().build(null, true);
class NodeWrapper {
#self
constructor(node = new Doom()){
this.#self = node;
}
clean(child=(n=new Doom())=>new Doom()){
child(this.#self).delete();
return this
}
cleanMany(children=(n=new Doom())=>[new Doom()]){
children(this.#self).forEach(child=>child.delete());
return this
}
update(change=(n=new Doom())=>{}){
change(this.#self);
return this
}
conciliate=()=>changing(()=>this.#self).then(()=>this)
}
const use = (node = new Doom())=> new NodeWrapper(node);
const routerSlot = (node = new Doom(), child = new Doom()) =>
use(node.routerSlot)
.clean(e=>e.main)
.conciliate()
.then(u=>u
.update(e=>e.main = { child })
.conciliate()
);
class Router{
#route
static App(router = new Router(),update=(child)=>{}){
window.addEventListener('popstate',(event)=>{
if (event.state) Router
.go('browser',router)
.then(update);
});
}
constructor(route={}){
this.#route = route;
}
matchUrl = () => this.match(window.location.pathname,window.location.search)
notFound=()=>Doom.$(
'not-found',
{
h1:{
content:'Page Not Found'
},
p:{
content:'404'
}
}
)
#matching=(link='')=>{
for (const [r,v] of Object.entries(this.#route)){
const paramSearch = /\:[a-zA-Z]+/g;
const search = r.replaceAll(paramSearch,'[A-Za-z0-9]+');
const matcher = new RegExp(search);
if (matcher.test(link)) return {
paramSearch,
matcher,
route:r,
value:v
}
}
return null
}
match(link='',search=''){
const destination = `${window.location.protocol}//${window.location.host}${link}`;
const match = this.#matching(link);
if (match){
const linkTokens = link.split('/');
const routeTokens = match.route.split('/');
const params = routeTokens
.reduce((p,a,i)=>{
if (match.paramSearch.test(a)) {
const param = decodeURI(linkTokens[i]);
const numParam = Number.parseFloat(param);
p[a.replace(':','')] = Number.isNaN(numParam) ? param : numParam;
}
return p
},{});
const query = (search==='')
?{}
:search
.replace('?','')
.split('&')
.reduce((p,a)=>{
const [key,value] = a.split('=');
p[key]=value;
return p
},{});
history.pushState({},'',destination);
return match.value({query,params})
}
return this.notFound()
}
static go(url='',router = new Router()){
const [link,search] = url.split('?');
const match = url==='browser'
? router.matchUrl()
: router.match(link,search);
return (match instanceof Router) ? Router.go(url,match) : match
}
}
class BaseElement extends HTMLElement{
constructor(){
super();
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) return
this[name] = newValue;
}
}
class CthulhuElement extends BaseElement{
#dom
#doom
constructor({
doom = async () => new Doom(),
css= [Promise.resolve(new CSSStyleSheet)]
}){
super();
this.#dom = this.attachShadow({mode:'open'});
const render = () => doom()
.then(d=>{
d.renderOn(this.#dom);
this.#doom = d;
});
if (!!css)
Promise
.all(css)
.then(sheets=>
this
.#dom
.adoptedStyleSheets = sheets
)
.then(render);
else
render();
}
get doom(){ return this.#doom }
}
class TextElement extends BaseElement{
constructor(template = ''){
super();
const shadow = this.attachShadow({mode:'open'});
shadow.innerHTML = template;
}
}
const css = (text)=>new CSSStyleSheet().replace(text);
const naming=(type=Doom)=>pascalOrCamelToKebab(type.name);
export { BaseElement, CthulhuElement, Doom, Router, TextElement, changing, css, naming, routerSlot, use };