PageSourceSearch

https://prat.ee/k/js/framework.js

js prat.ee collected 2026-10-09 01:38:52 UTC 21,141 bytes, 488 lines download raw bytes

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.