1// bas: a small SPA runtime in one file. Routes, hooks, delegated events, 2// DOM morphing. "Bas" is Hindi for "enough", which is the design goal. 3// 4// A page is a function that returns an HTML string. It is called with 5// { params, query, path, hash } and may be async. Hooks work by call order, 6// like React. Effects run after the DOM is patched and may return a cleanup, 7// which runs before the effect re-runs and when the page is left. 8// 9// A route is a page function, or an object that says a little more: where to 10// load the page from, what goes in <head>, and whether the page can be 11// rendered without a browser (see prerender() at the bottom). 12 13let hooks = []; // hook slots for the current page 14let hookIndex = 0; 15let pageGen = 0; // bumps on every route change; stale closures check it 16let pendingEffects = []; // collected during render, flushed after the DOM patch 17let eventHandlers = {}; // data-action name -> handler, cleared on route change 18let app = null; // the Framework instance, for navigate() 19 20// ââ Hooks ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 21 22export function useState(initialValue) { 23 const i = hookIndex++; 24 if (!(i in hooks)) { 25 hooks[i] = { value: typeof initialValue === 'function' ? initialValue() : initialValue }; 26 } 27 const slot = hooks[i]; 28 const gen = pageGen; 29 const setState = (next) => { 30 if (gen !== pageGen) return; // the page that owned this state is gone 31 const value = typeof next === 'function' ? next(slot.value) : next; 32 if (Object.is(slot.value, value)) return; 33 slot.value = value; 34 if (app) app.scheduleRender(); 35 }; 36 return [slot.value, setState]; 37} 38 39function depsChanged(prev, next) { 40 if (!prev || !next) return true; // no deps: run every render 41 if (prev.length !== next.length) return true; 42 return next.some((d, k) => !Object.is(d, prev[k])); 43} 44 45export function useEffect(callback, dependencies) { 46 const i = hookIndex++; 47 const slot = hooks[i] || (hooks[i] = { deps: undefined, cleanup: null, ran: false, dead: false }); 48 if (slot.ran && !depsChanged(slot.deps, dependencies)) return; 49 slot.ran = true; 50 slot.deps = dependencies; 51 pendingEffects.push(() => { 52 runCleanup(slot); 53 const result = callback(); 54 if (typeof result === 'function') { 55 slot.cleanup = result; 56 } else if (result && typeof result.then === 'function') { 57 // async effect: the resolved value may be a cleanup. If the page is 58 // already gone by then, run it straight away. 59 result.then(c => { 60 if (typeof c !== 'function') return; 61 if (slot.dead) c(); else slot.cleanup = c; 62 }); 63 } 64 }); 65} 66 67function runCleanup(slot) { 68 const c = slot.cleanup; 69 slot.cleanup = null; 70 if (typeof c === 'function') { 71 try { c(); } catch (err) { console.error('[framework] effect cleanup failed', err); } 72 } 73} 74 75function unmountPage() { 76 hooks.forEach(slot => { if (slot && 'cleanup' in slot) { runCleanup(slot); slot.dead = true; } }); 77 hooks = []; 78 pendingEffects = []; 79 eventHandlers = {}; 80 pageGen++; 81} 82 83function flushEffects() { 84 const fx = pendingEffects; 85 pendingEffects = []; 86 fx.forEach(f => { 87 try { f(); } catch (err) { console.error('[framework] effect failed', err); } 88 }); 89} 90 91// ââ Events âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 92 93/** 94 * Register a handler by name for elements with data-action="name". 95 * Handlers persist across re-renders of a page and are cleared when the 96 * route changes, so a page can register them at module level or inside its 97 * render function; the latter simply overwrites each time. 98 */ 99export function registerHandler(name, fn) { 100 eventHandlers[name] = fn; 101} 102 103/** Navigate from code. Same rules as a data-link click. */ 104export function navigate(url, options = {}) { 105 if (app) app.navigateTo(url, options); 106} 107 108// ââ DOM morphing âââââââââââââââââââââââââââââââââââââââââââââââââââââ 109// Patches the existing DOM in place instead of replacing innerHTML, so
110// focus, scroll position and CSS transitions survive a re-render. A subtree 111// marked data-morph-ignore is left alone entirely; use it for regions a page 112// manages by hand (canvases, third-party widgets). 113 114function morphChildren(oldParent, newParent) { 115 const newNodes = Array.from(newParent.childNodes); 116 117 for (let i = 0; i < newNodes.length; i++) { 118 const oldNode = oldParent.childNodes[i]; 119 const newNode = newNodes[i]; 120 if (!oldNode) oldParent.appendChild(newNode.cloneNode(true)); 121 else morphNode(oldParent, oldNode, newNode); 122 } 123 while (oldParent.childNodes.length > newNodes.length) { 124 oldParent.removeChild(oldParent.lastChild); 125 } 126} 127 128function morphNode(parent, oldNode, newNode) { 129 if (oldNode.nodeType !== newNode.nodeType) { 130 parent.replaceChild(newNode.cloneNode(true), oldNode); 131 return; 132 } 133 if (oldNode.nodeType === Node.TEXT_NODE || oldNode.nodeType === Node.COMMENT_NODE) { 134 if (oldNode.textContent !== newNode.textContent) oldNode.textContent = newNode.textContent; 135 return; 136 } 137 if (oldNode.nodeType === Node.ELEMENT_NODE) { 138 if (oldNode.tagName !== newNode.tagName) { 139 parent.replaceChild(newNode.cloneNode(true), oldNode); 140 return; 141 } 142 // only when both sides opt out: a page change must still replace it 143 if (oldNode.hasAttribute('data-morph-ignore') && newNode.hasAttribute('data-morph-ignore')) return; 144 morphAttributes(oldNode, newNode); 145 morphChildren(oldNode, newNode); 146 } 147} 148 149function morphAttributes(oldEl, newEl) { 150 for (const attr of Array.from(oldEl.attributes)) { 151 if (!newEl.hasAttribute(attr.name)) oldEl.removeAttribute(attr.name); 152 } 153 for (const attr of Array.from(newEl.attributes)) { 154 if (oldEl.getAttribute(attr.name) !== attr.value) oldEl.setAttribute(attr.name, attr.value); 155 } 156} 157 158// ââ Routing ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 159 160/** Strip a trailing slash, keep a leading one. */ 161function normalizePath(p) { 162 if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1); 163 return p || '/'; 164} 165 166// Routes are exact paths, or patterns with :name segments. '*' is the 167// not-found page. The value is a page function, or an object: 168// 169// page the page function, or 170// load () => import('./page.js'), called the first time the route is 171// visited. Resolves to a module with a default export, or to 172// the page function itself (for a named export, add a .then) 173// head { title, description, type, noindex } for this route, or a 174// function of { params, path } returning that 175// prerender true if the page returns the same HTML without a browser 176// paths () => [...] the real paths behind a :param route, for build 177// scripts. On an exact route, () => [] keeps it out of the build 178function compileRoutes(routes) { 179 const exact = new Map(); 180 const patterns = []; 181 let notFound = null; 182 for (const [key, value] of Object.entries(routes)) { 183 const route = typeof value === 'function' ? { page: value } : value; 184 if (key === '*') { notFound = route; continue; } 185 const path = normalizePath(key); 186 if (!path.includes(':')) { exact.set(path, route); continue; } 187 const keys = []; 188 const source = path.split('/').map(seg => { 189 if (!seg.startsWith(':')) return seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 190 keys.push(seg.slice(1)); 191 return '([^/]+)'; 192 }).join('/'); 193 patterns.push({ regex: new RegExp('^' + source + '$'), keys, route }); 194 } 195 return { exact, patterns, notFound }; 196} 197 198/** A route-relative path ('/snake', no base, no trailing slash) to { path, route, params }. */ 199function matchRoute(table, path) { 200 const exact = table.exact.get(path); 201 if (exact) return { path, route: exact, params: {} }; 202 for (const p of table.patterns) { 203 const m = path.match(p.regex); 204 if (!m) continue; 205 const params = {}; 206 p.keys.forEach((k, i) => { params[k] = decodeURIComponent(m[i + 1]); }); 207 return { path, route: p.route, params }; 208 } 209 return { path, route: table.notFound, params: {}, notFound: true }; 210} 211 212async function loadPage(route) { 213 const m = await route.load(); 214 return typeof m === 'function' ? m : m.default; 215} 216 217// Site defaults, then the route's own. A path that matched nothing is never indexed. 218function headOf(match, defaults) { 219 const h = match.route && match.route.head; 220 return { ...defaults, noindex: !!match.notFound, ...(typeof h === 'function' ? h(match) : h) }; 221} 222 223export default class Framework { 224 // options: { base, origin, head }. A plain string is taken as the base path.
225 constructor(routes, options = {}) { 226 if (typeof options === 'string') options = { base: options }; 227 this.routes = compileRoutes(routes); 228 this.basePath = options.base || ''; // e.g. '/k' 229 this.origin = options.origin || location.origin; // for canonical URLs 230 this.head = options.head || null; // site-wide <head> defaults; leave out and bas never touches <head> 231 this.root = document.getElementById('app'); 232 this.current = null; // { path, route, params } 233 this._painted = false; 234 this._renderId = 0; 235 this._scheduled = false; 236 this._observer = null; 237 238 app = this; 239 this.init(); 240 } 241 242 isInBasePath(path) { 243 if (!this.basePath) return false; 244 return path === this.basePath || path.startsWith(this.basePath + '/'); 245 } 246 247 /** A route-relative path ('/snake') as a real URL path ('/k/snake'). */ 248 withBase(path) { 249 if (!this.basePath || this.isInBasePath(path)) return path; 250 return this.basePath + path; 251 } 252 253 init() { 254 if ('scrollRestoration' in history) history.scrollRestoration = 'manual'; 255 window.addEventListener('popstate', () => this.handleRoute('pop')); 256 257 document.body.addEventListener('click', e => { 258 const link = e.target.closest('[data-link]'); 259 if (link) { 260 // leave new-tab, download and modified clicks to the browser 261 if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; 262 if (link.getAttribute('target') === '_blank' || link.hasAttribute('download')) return; 263 e.preventDefault(); 264 this.navigateTo(link.getAttribute('href')); 265 return; 266 } 267 const actionEl = e.target.closest('[data-action]'); 268 if (actionEl) { 269 const fn = eventHandlers[actionEl.getAttribute('data-action')]; 270 if (fn) fn(e); 271 } 272 }); 273 274 this.handleRoute('load'); 275 } 276 277 navigateTo(url, { replace = false } = {}) { 278 const target = new URL(url, location.href); 279 if (target.origin !== location.origin) { location.href = url; return; } 280 if (url.startsWith('/')) target.pathname = this.withBase(target.pathname); 281 const next = target.pathname + target.search + target.hash; 282 const here = location.pathname + location.search + location.hash; 283 if (next === here) { this.scrollAfterNavigation(); return; } 284 285 // remember where we were so the back button can put us back there 286 history.replaceState({ ...(history.state || {}), scrollY: window.scrollY }, ''); 287 if (replace) history.replaceState(null, '', next); 288 else history.pushState(null, '', next); 289 this.handleRoute(replace ? 'replace' : 'push'); 290 } 291 292 resolve(pathname) { 293 let path = pathname; 294 if (this.isInBasePath(path)) path = path.slice(this.basePath.length) || '/'; 295 return matchRoute(this.routes, normalizePath(path)); 296 } 297 298 async handleRoute(reason = 'push') { 299 const match = this.resolve(location.pathname); 300 const changed = !this.current 301 || this.current.path !== match.path 302 || this.current.route !== match.route; 303 if (changed) { 304 unmountPage(); 305 if (this._observer) { this._observer.disconnect(); this._observer = null; } 306 } 307 this.current = match; 308 await this.update(); 309 // a same-page hash link (a contents list, say) also needs a scroll 310 if (changed || location.hash) this.scrollAfterNavigation(reason); 311 } 312 313 scrollAfterNavigation(reason = 'push') { 314 if (reason === 'pop' && history.state && typeof history.state.scrollY === 'number') { 315 window.scrollTo(0, history.state.scrollY); 316 return; 317 } 318 if (location.hash) { 319 const el = document.getElementById(decodeURIComponent(location.hash.slice(1))); 320 if (el) { el.scrollIntoView(); return; } 321 } 322 if (reason !== 'load') window.scrollTo(0, 0); 323 } 324 325 /** Coalesce several setState calls into one render. */ 326 scheduleRender() { 327 if (this._scheduled) return; 328 this._scheduled = true; 329 queueMicrotask(() => { this._scheduled = false; this.update(); }); 330 } 331
332 async update() { 333 const id = ++this._renderId; 334 const gen = pageGen; 335 const match = this.current; 336 const route = match.route; 337 338 // a lazy page: fetch it once. Whatever is on screen (the previous 339 // page, or prerendered HTML on a deep link) stays up until it arrives. 340 if (route && !route.page) { 341 try { 342 route.page = await loadPage(route); 343 } catch (err) { 344 console.error('[framework] could not load the page for', match.path, err); 345 // most likely a deploy swapped the files under us, and a real 346 // page load sorts that out. Not on first paint, or it would loop. 347 if (this._painted) location.reload(); 348 return; 349 } 350 if (id !== this._renderId || gen !== pageGen) return; 351 } 352 hookIndex = 0; // after the await, so a page that lost the race never touches hook slots 353 354 let viewHtml; 355 if (!route) { 356 viewHtml = `<h1>404 - Not Found</h1><p>The requested path ${match.path} does not exist.</p>`; 357 } else { 358 const ctx = { 359 params: match.params, 360 query: new URLSearchParams(location.search), 361 path: match.path, 362 hash: location.hash, 363 }; 364 viewHtml = await route.page(ctx); 365 } 366 // a newer render or a route change overtook this one while it awaited 367 if (id !== this._renderId || gen !== pageGen) return; 368 369 // Prerendered HTML in #app takes the morph path like any re-render; it 370 // is the same string, so nothing moves and nothing flashes. 371 const activeId = document.activeElement?.id; 372 if (!this.root.hasChildNodes()) { 373 this.root.innerHTML = viewHtml; 374 } else { 375 const temp = document.createElement('div'); 376 temp.innerHTML = viewHtml; 377 morphChildren(this.root, temp); 378 } 379 if (activeId && document.activeElement?.id !== activeId) { 380 const el = document.getElementById(activeId); 381 if (el && typeof el.focus === 'function') el.focus(); 382 } 383 384 this._painted = true; 385 this.decorateLinks(); 386 this.applyHead(match); 387 flushEffects(); 388 this.afterRender(); 389 if (typeof this.onRender === 'function') this.onRender(match); 390 } 391 392 // Keep <head> right during client-side navigation: title, description, 393 // canonical, robots and the social tags. It only updates tags the shell 394 // already has. Crawlers get the same values baked in by the build script. 395 applyHead(match) { 396 if (!this.head) return; 397 const head = headOf(match, this.head); 398 const url = this.origin + this.basePath + (match.path === '/' ? '/' : match.path); 399 const set = (selector, attr, value) => { 400 const el = document.head.querySelector(selector); 401 if (el && value != null) el.setAttribute(attr, value); 402 }; 403 if (head.title) document.title = head.title; 404 set('meta[name="description"]', 'content', head.description); 405 set('meta[name="robots"]', 'content', head.noindex ? 'noindex, follow' : (head.robots || 'index, follow')); 406 set('link[rel="canonical"]', 'href', url); 407 set('meta[property="og:title"]', 'content', head.title); 408 set('meta[property="og:description"]', 'content', head.description); 409 set('meta[property="og:url"]', 'content', url); 410 set('meta[property="og:type"]', 'content', head.type || 'website'); 411 set('meta[name="twitter:title"]', 'content', head.title); 412 set('meta[name="twitter:description"]', 'content', head.description); 413 } 414 415 // Give data-links real hrefs (so open-in-new-tab and copy-link work) and 416 // mark the one for the current page. 417 decorateLinks() { 418 const here = normalizePath(location.pathname); 419 this.root.querySelectorAll('[data-link]').forEach(a => { 420 const href = a.getAttribute('href'); 421 if (!href || !href.startsWith('/')) return; 422 const [, path, rest] = href.match(/^([^?#]*)(.*)$/); 423 const full = this.withBase(path); 424 if (full !== path) a.setAttribute('href', full + rest); 425 if (normalizePath(full) === here) a.setAttribute('aria-current', 'page'); 426 else a.removeAttribute('aria-current'); 427 }); 428 } 429 430 afterRender() { 431 if (!this._observer) { 432 this._observer = new IntersectionObserver((entries) => {
433 entries.forEach(entry => { 434 if (entry.isIntersecting) { 435 entry.target.classList.add('visible'); 436 this._observer.unobserve(entry.target); 437 } 438 }); 439 }, { threshold: 0.1 }); 440 } 441 this.root.querySelectorAll('.reveal:not(.visible)').forEach(el => this._observer.observe(el)); 442 } 443} 444 445// ââ Without a browser ââââââââââââââââââââââââââââââââââââââââââââââââ 446// For build scripts. Nothing below touches the DOM, so Node can import this 447// file and the route table and write real HTML for crawlers, link previews 448// and anyone with JavaScript off. bas then morphs over that HTML on boot. 449 450/** Every concrete path the routes stand for: exact paths, plus paths() of the :param ones. */ 451export function staticPaths(routes) { 452 const out = []; 453 for (const [key, value] of Object.entries(routes)) { 454 if (key === '*') continue; 455 if (typeof value.paths === 'function') out.push(...value.paths()); 456 else if (!key.includes(':')) out.push(key); 457 } 458 return [...new Set(out.map(normalizePath))]; 459} 460 461/** 462 * What a route-relative path comes to: { path, params, notFound, head, url, html }. 463 * head and url are what applyHead() would set in a browser. html is the page 464 * itself when the route says prerender: true, with data-link hrefs already 465 * given their base path, and null otherwise. Effects never run here. 466 * Takes the same options as the constructor; origin is required. 467 */ 468export async function prerender(routes, path, options = {}) { 469 const base = options.base || ''; 470 const match = matchRoute(compileRoutes(routes), normalizePath(path)); 471 const head = headOf(match, options.head); 472 const url = options.origin + base + (match.path === '/' ? '/' : match.path); 473 474 let html = null; 475 if (match.route && match.route.prerender) { 476 const page = match.route.page || (match.route.page = await loadPage(match.route)); 477 hooks = []; hookIndex = 0; pendingEffects = []; 478 html = await page({ params: match.params, query: new URLSearchParams(), path: match.path, hash: '' }); 479 hooks = []; pendingEffects = []; 480 // what decorateLinks() does in a browser, so the links work before bas boots 481 if (base) { 482 html = html.replace(/<a\b[^>]*\bdata-link\b[^>]*>/g, tag => 483 tag.replace(/\bhref="(\/[^"]*)"/, (m, href) => 484 href === base || href.startsWith(base + '/') ? m : `href="${base}${href}"`)); 485 } 486 } 487 return { ...match, head, url, html }; 488}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.