const tabId = `${Date.now()}${Math.floor(Math.random() * 100)}`; const channelId = `kaji-${tabId}`; const channelPath = `/~/channel/${channelId}`; let channelMessageId = 0; const parser = new DOMParser(); addEventListener('DOMContentLoaded', async () => { await openChannel(); scanElements(document); handleDefaultScroll(); }); function handleDefaultScroll(){ const params = new URLSearchParams(window.location.search); const goto = params.get('goto'); if (goto) { const target = document.getElementById(goto); if (target) target.scrollIntoView({block: "center"}); else (console.log(goto, "target not found")) } } function insertAndScan(parent, htmlString){ parent.innerHTML = htmlString; scanElements(parent); } function scanElements(el){ const isEl = el instanceof HTMLElement; if (!el) return const elements = el.querySelectorAll('[kaji]'); if (isEl && el.getAttribute("kaji")) setListener(el); elements.forEach(el => setListener(el)); } function loadScripts(el){ const scripts = el.querySelectorAll("script"); for (let scr of scripts){ const newScript = document.createElement('script'); if (scr.src) newScript.src = scr.src else if (scr.textContent) newScript.textContent = scr.textContent document.body.appendChild(newScript); } } function setLocaleTime(el){ const els = el.querySelectorAll(".locale-dt"); for (let de of els){ const timestamp = Number(de.textContent); // TODO error handle the parsing const date = new Date(timestamp); // TODO set the format at the server let localDate = ""; if (de.classList.includes("time")) localDate = date.toLocaleDateString(); if (de.classList.includes("date")) localDate = date.toLocaleTimeString(); else localDate = date.toLocaleString(); de.textContent = localDate; } } async function openChannel(){ console.log(window.ship, "opening channel") const sub1 = makeSubscribeBody(window.ship, window.app, window.liveUI); const ssePath = `/sse/${tabId}`; const sub2 = makeSubscribeBody(window.ship, window.app, ssePath); const pok = makePokeBody(window.ship, "hood", "helm-hi", "hai") const body = JSON.stringify([sub1, sub2, pok]) await fetch(channelPath, { method: 'PUT', body }); eventSource = new EventSource(channelPath); eventSource.addEventListener('message', handleChannelStream); } async function put(bodies){ console.log({channelPath, bodies}, "put body") const res = await fetch(channelPath, { method: "PUT", body: JSON.stringify(bodies) }) return await res } function subscribe(path){ document.addEventListener('DOMContentLoaded', () => { const sub = makeSubscribeBody(window.ship, window.app, path); put([sub]) }); } function handleChannelStream(e){ const streamResponse = JSON.parse(e.data); if (streamResponse.response !== 'diff') return; sendAck(streamResponse.id) const res = streamResponse.json; if (!res || res?.length === 0) return; for (let effect of res){ console.log(effect, "sse command") if ("refresh" in effect) clearBrowserCache() if ("redi" in effect) redirect(effect.redi) if ("focus" in effect) handleFocus(effect.focus) if ("scroll" in effect) handleScroll(effect.scroll) if ("url" in effect) updateURL(effect.url) if ("custom" in effect) dispatchFact(effect.custom) else { const key = Object.keys(effect)[0]; const htmlData = effect[key].manx; const nel = parser.parseFromString(htmlData, 'text/html').body.firstChild; if ("swap" in effect) swapTarget(null, nel, effect.swap.sel, effect.swap.inner); if ("add" in effect) addEls(null, nel, effect.add.container, effect.add.where) if ("modal" in effect) showModal(nel) if ("alert" in effect) showAlert(nel, effect.alert.duration) } } } function clearBrowserCache(){ window.location.reload(true); } function handleFocus(sel){ const el = document.querySelector(sel); if (el) el.focus(); } function handleScroll(sel){ const el = document.querySelector(sel); if (el) el.scrollIntoView(); } function updateURL(url){ window.history.pushState(null, null, url) } function redirect(url){ // document.body.innerHTML = html; // scanElements(document.body); // history.pushState({}, '', url) console.log(url, "redirecting") window.location.href = url; } function dispatchFact(detail){ const myCustomEvent = new CustomEvent("kaji-fact", { detail }); document.dispatchEvent(myCustomEvent); } function partialManx(newElement, selector){ const existing = document.getElementById(newElement.id); if (newElement && existing) existing.parentNode.replaceChild(newElement, existing); else { const toReplace = document.querySelector(selector); toReplace.parentNode.replaceChild(newElement, toReplace); } } function addManx(newElement, selector){ const container = document.getElementById(selector); container.appendChild(newElement); } function wrapInModal(el){ const background = document.createElement("div"); background.id = "kaji-modal-bg"; const foreground = document.createElement("div"); foreground.id = "kaji-modal-fg"; // Append modal content to modal foreground.appendChild(el); background.appendChild(foreground); document.body.appendChild(background); // Click outside to close window.onclick = function(event) { if (event.target == background) background.remove(); } } function showModal(html){ const background = document.createElement("div"); background.id = "kaji-modal-bg"; const foreground = document.createElement("div"); foreground.id = "kaji-modal-fg"; insertAndScan(foreground, html) // Append modal content to modal background.appendChild(foreground); document.body.appendChild(background); // Click outside to close window.onclick = function(event) { if (event.target == background) background.remove(); } } function showAlert(element, duration){ const div = document.createElement("div"); div.id = "kaji-alert"; div.appendChild(element); document.body.appendChild(div); setTimeout(() => document.body.removeChild(div), duration) } function passAttributes(parent, child){ for (let attr of parent.attributes){ const n = attr.name; const v = attr.value; if (n === "class" || n === "id") continue; const haveIt = child.getAttribute(n); if (v && !haveIt) child.setAttribute(n, v) } } function findForm(el){ const is = isFormChild(el); if (!is) return let form; let parent = el.parentElement; while (!form){ if (!parent) break; passAttributes(parent, el) const tag = parent.tagName.toLowerCase(); if (tag === "form") form = parent; else parent = parent.parentElement; } if (form) el.params = getFormData(form) } function isFormChild(el){ const tag = el.tagName.toLowerCase(); return (tag === "input" || tag === "select" || tag === "option" || tag === "textarea" || tag === "button") } function setListener(el){ const kaji = el.getAttribute('kaji'); // immediate calls // we're not gonna bother with settings params here. if (kaji === "mobile") fetchMobile(el) else if (kaji === "fetch") fetchSource(el) else if (kaji === "iscroll") infiniteScroll(el) else if (kaji === "search") handleLiveSearch(el) else if (kaji === "clickaway") clickaway(el) // event listeners else { const trigger = el.getAttribute("trigger") || defaultTrigger(el); el.addEventListener(trigger, (e) => { routeKaji(e, el, kaji) }) } } function routeKaji(e, el, kaji){ if (kaji === "navi") navigate(e, el) if (kaji === "modal") mmodal(e, el) if (kaji === "toggle") toggle(e, el) if (kaji === "destroy") destroy(e, el) // urbit stuff if (kaji === "scry") scry2(e) if (kaji === "watch") watch(e, el) if (kaji === "poke") poke(e, el) if (kaji === "thread") thread(e, el) } function clickaway(el){ window.onclick = function(event) { if (el.contains(event.target)) return else el.hidden = true; } } function destroy(e, el){ e.stopPropagation(); const targ = e.target.getAttribute("targ"); if (!targ) return const targs = targ.split("/").map(m => lookabove(e.target, m)); for (let t of targs){ if (t) t.parentElement.removeChild(t); } } function bail(string){ console.log(string) return } function handleLiveSearch(el){ const bounceSetting = el.getAttribute("bounce"); const num = Number(bounceSetting || 0); if (Number.isNaN(num)) return bail("bounce setting NaN"); // const debounce = (fn, delay) => { let timeout; return (...args) => { clearTimeout(timeout); timeout = setTimeout(() => fn(...args), delay) } } const handler = (e) => scry(e, el); const debounced = debounce(handler, num); el.addEventListener("keyup", debounced); } function isNarrow(){ return window.innerWidth < 768; // TODO } async function fetchMobile(el){ const path = el.getAttribute('path'); if (!isNarrow()) return const res = await fetch(path); const html = await res.text(); const nel = parser.parseFromString(html, 'text/html').body.firstChild; scanElements(nel); el.replaceWith(nel); } async function fetchSource(el){ const res = await fetch(el.getAttribute("src")); const html = await res.text(); const nel = parser.parseFromString(html, 'text/html').body.firstChild; el.replaceWith(nel) } function infiniteScroll(el){ let observer = new IntersectionObserver((entries, observer) => { entries.forEach(entry => { if (entry.isIntersecting) { const spinner = replaceWithSpinner(entry.target); const url = getURLwithParams(el); routerScry(url).then(nel => { if (!nel) return const cont = entry.target.getAttribute("cont"); // TODO addEls(el, nel, cont, addWhere(el)) dispatchScry({swapMode: "add", container: cont, element: nel}) spinner.remove() }); observer.unobserve(entry.target); } }) }); observer.observe(el); } function replaceWithSpinner(el){ const spinner = document.createElement("img"); spinner.src = "spinner.svg"; spinner.classList.add("iscroll-spinner") el.replaceWith(spinner); return spinner; } function handleError(res, el){ if(!el) { console.log(res, "No element found scrying") return true } if (el.id === "kaji-alert") { const dur = el.getAttribute("dur"); const num = Number(dur); showAlert(el, num); return true } const err = el.getAttribute("kaji-error"); if (!err) return false showError(el); return true } function showError(el){ return } function lookabove(el, sel){ console.log(el, "looking above") console.log(sel, "sel") if (!sel) { console.log(sel, "selector not found") return null } if (sel[0] === "#") return document.getElementById(sel.slice(1)); if (!el) return null const found = el.querySelector(sel); if (!found) return lookabove(el.parentElement, sel) else return found } function toggle(e, el){ e.stopPropagation(); const targ = e.target.getAttribute("targ"); if (!targ) return const targs = targ.split("/").map(m => lookabove(e.target, m)); targs.forEach(t => { if (e.target.getAttribute("modal")) wrapInModal(t); if (t) t.hidden = !t.hidden }) } async function routerScry(path){ console.log(path, "router scry") const res = await fetch(path) const html = await res.text() const nel = parser.parseFromString(html, 'text/html').body.firstChild; if (handleError(html, nel)) return null scanElements(nel); loadScripts(nel); return nel } function dispatchScry(detail){ const myCustomEvent = new CustomEvent("kaji-scry", { detail }); document.dispatchEvent(myCustomEvent); } function addWhere(el){ const where = el.getAttribute("where") if (!where) return { bottom: null } if (where === "bottom") return { bottom: null } if (where === "top") return { top: null } if (where === "before") { const sibling = el.getAttribute("sibling"); if (!sibling) return null else return { before: sibling } } else return null } function addEls(originEl, nel, selector, where){ if (!where) bail("no where given to addEls") const container = originEl ? lookabove(originEl, selector) : document.querySelector(selector); if (!container) bail("no container given to addEls") while (nel.firstChild){ if ("top" in where) container.prepend(nel.firstChild) else if ("bottom" in where) container.appendChild(nel.firstChild) else if ("before" in where){ const sibling = document.querySelector(where.before); if (!sibling) bail ("sibling at addEls doesn't exist") container.insertBefore(nel.firstChild, sibling); } } } function defaultTrigger(el){ const tag = el.tagName.toLowerCase(); if (tag === "form") return "submit" if (tag === "input" || tag === "textarea" || tag === "select") return "change" else return "click"; } function toggleIndicator(el, on){ const indicator = el.getAttribute('indicator'); const indEl = document.querySelector(indicator); if (indEl) indEl.style.display = on ? "block" : "none"; } function getURLwithParams(el){ findForm(el); const spath = el.getAttribute("path"); const path = spath || window.location.href; // decide on priority when conflict const current = new URLSearchParams(window.location.search); const [pat, pars] = path.split("?"); if (pars) { const param = new URLSearchParams(pars); for (let [k, v] of param.entries()){ current.set(k, v) } } const name = el.getAttribute("name"); const params = el.params ? el.params : el.tagName === 'FORM' ? getFormData(el) : {}; const second = new URLSearchParams(params); const final = new URLSearchParams(); for (let [k, v] of current.entries()){ final.set(k, v) } if (name && el.value) final.set(name, el.value); for (let [k, v] of second.entries()){ final.set(k, v) } const url = `${pat}?${final.toString()}`; return url } function consolidateAttributes(el){ let parent = el.parentElement; while (parent){ passAttributes(parent, el); parent = parent.parentElement; } } async function scry2(e){ console.log(e, "scry event") // this should go to kaji root const el = e.target; consolidateAttributes(el); // // this should go to url building function const path = el.getAttribute("path") || window.location.href; const params = new URLSearchParams(window.location.search); const [pat, parString] = path.split("?"); if (parString) { const newParams = new URLSearchParams(parString); for (let [k, v] of newParams.entries()){ params.set(k, v) } } const name = el.getAttribute("name"); if (name) params.set(name, el.value); const url = `${path}?${params.toString()}`; // scry proper e.preventDefault(); const swapMode = el.getAttribute("swap") || "swap"; toggleIndicator(el, true); const nel = await routerScry(url); console.log(nel, "nel") if (!nel) return const showParams = el.getAttribute("show-params"); if (showParams) raiseParams(url); let container; if (swapMode === "swap"){ toggleTab(el); const target = el.getAttribute('targ') const swhere = el.getAttribute('where'); const where = !swhere ? true : (swhere === "inner") container = swapTarget(el, nel, target, where) } else if (swapMode === "add"){ const cont = el.getAttribute('cont') container = await addEls(el, nel, cont, addWhere(el)); } toggleIndicator(el, false); if (container) dispatchScry({swapMode, container}) } async function scry(e, el){ console.log(e, "scry event") e.preventDefault(); const url = getURLwithParams(el); console.log(url, "url") toggleIndicator(el, true); const swapMode = el.getAttribute("swap"); const nel = await routerScry(url); if (!nel) return const showParams = el.getAttribute("show-params"); if (showParams) raiseParams(url); let container; if (swapMode === "swap"){ toggleTab(el); const target = el.getAttribute('targ') const swhere = el.getAttribute('where'); const where = !swhere ? true : (swhere === "inner") container = swapTarget(el, nel, target, where) } else if (swapMode === "add"){ const cont = el.getAttribute('cont') container = await addEls(el, nel, cont, addWhere(el)); } toggleIndicator(el, false); if (container) dispatchScry({swapMode, container}) } function raiseParams(url){ const params = url.split("?")[1] || ""; const displayURL = `${window.location.pathname}?${params}`; updateURL(displayURL) } async function navigate(e, el){ e.preventDefault(); const path = el.getAttribute('path'); const href = findAnchor(e.target); const uri = path ? path : href; if (!uri) throw new Error("no fetch path found") const res = await fetch(uri); const html = await res.text() // If in a modal, replace the modal content with this const modal = e.target.closest("#kaji-modal-fg"); if (modal) insertAndScan(modal, html) else insertAndScan(document.querySelector("body"), html); } function findAnchor(el){ const a = el.closest("a"); if (a) return a.href else return null } async function mmodal(e, el){ const path = el.getAttribute('path'); const href = findAnchor(e.target); const uri = path ? path : href; if (!uri) throw new Error("no fetch path found") const res = await fetch(uri); const html = await res.text(); showModal(html); } function hasSetTarget(el){ return !!el.attributes.targ } function toggleTab(el){ if (!el.classList.contains("tab")) return; const par = el.parentElement; if (!el.classList.contains("active")){ const curr = el.parentElement.querySelector(".active"); curr.classList.remove("active"); el.classList.add("active"); } } function swapTarget(originEl, nel, targetSelector, inner){ const domEL = originEl ? lookabove(originEl, targetSelector) : document.querySelector(targetSelector); if (!domEL) { console.log("no container", targetSelector) return } if (inner){ domEL.innerHTML = ''; while (nel.firstChild){ domEL.appendChild(nel.firstChild); } } else domEL.parentElement.replaceChild(nel, domEL) return domEL } async function poke(e, el){ e.preventDefault(); const ship = el.getAttribute('ship'); const app = el.getAttribute('app'); const mark = el.getAttribute('mark') || 'kaji'; const json = el.getAttribute('json'); console.log(el.params, "poke params?") const tgt = e.currentTarget; const button = tgt.tagName === 'FORM' ? tgt.querySelector('input[type="submit"]') : tgt; // TODO add flag to configure this button.disabled = true; // wipe form const action = tgt.getAttribute('action'); const name = tgt.getAttribute("name"); const payload = tgt.getAttribute('payload'); // const value = payload ? payload : tgt.value; const body = json ? json : (tgt.tagName === 'FORM') ? {action, ...getFormData(tgt)} : action ? // if we set an action we send {action, name: value} {action, [name]: payload} : {action: name, [name]: payload}; e.stopPropagation(); // this cancels currentTarget going up and up if (tgt.tagName === 'FORM') wipeForm(tgt); const b = {...body, origin: window.location.pathname, tab: tabId}; console.log(b, "poke params") const bodies = [makePokeBody(window.ship, window.app, mark, b)] await put(bodies); button.disabled = false } function wipeForm(form){ const wipe = form.getAttribute("wipe"); if (!wipe) return const inputs = [ ...form.getElementsByTagName("input"), ...form.getElementsByTagName("textarea") ]; for (let input of inputs){ if (input.type.toLowerCase() === 'text') input.value = ''; if (input.tagName === "TEXTAREA") input.value = ''; } } function getFormData(form){ const d = new FormData(form); return Object.fromEntries(d) } async function watch(e, el){ const ship = el.getAttribute('ship'); const app = el.getAttribute('app'); const path = el.getAttribute('path'); const body = makeSubscribeBody(ship, app, path); return await fetch(channelPath, { method: 'PUT', body }); } // threads don't support noun payloads yet async function thread(e, el){ console.log(el.params, "thread params?") const desk = el.getAttribute('desk'); const thread = el.getAttribute('thread'); const inputMark = el.getAttribute('input-mark') || 'kaji'; const outputMark = el.getAttribute('output-mark') || 'kaji'; const noun = el.getAttribute('noun'); fetch(`/spider/${desk}/${inputMark}/${thread}/${outputMark}.kaji`, { headers: { 'Content-type': 'application/x-urb-jam' }, method: 'POST', body: noun }); } async function sendAck(id){ return await fetch(channelPath, { method: 'PUT', body: makeAck(id) }); } function makeSubscribeBody(ship, app, path) { channelMessageId++; return { id: channelMessageId, action: 'subscribe', ship: ship.slice(1), // fucking tilde crap app, path }; }; function makePokeBody(ship, app, mark, json) { channelMessageId++; return { id: channelMessageId, action: 'poke', ship: ship.slice(1), // fucking tilde crap app, mark, json }; }; function makeAck(eventId) { channelMessageId++; return JSON.stringify([{ id: channelMessageId, action: 'ack', "event-id": eventId }]); };