| | |
| | | import micromorph from "micromorph" |
| | | import { FullSlug, RelativeURL, getFullSlug, normalizeRelativeURLs } from "../../util/path" |
| | | import { fetchCanonical } from "./util" |
| | | |
| | | // adapted from `micromorph` |
| | | // https://github.com/natemoo-re/micromorph |
| | | |
| | | const NODE_TYPE_ELEMENT = 1 |
| | | let announcer = document.createElement('route-announcer') |
| | | const isElement = (target: EventTarget | null): target is Element => (target as Node)?.nodeType === NODE_TYPE_ELEMENT |
| | | let announcer = document.createElement("route-announcer") |
| | | const isElement = (target: EventTarget | null): target is Element => |
| | | (target as Node)?.nodeType === NODE_TYPE_ELEMENT |
| | | const isLocalUrl = (href: string) => { |
| | | try { |
| | | const url = new URL(href) |
| | | if (window.location.origin === url.origin) { |
| | | if (url.pathname === window.location.pathname) { |
| | | return !url.hash |
| | | } |
| | | return true |
| | | } |
| | | } catch (e) { } |
| | | } catch (e) {} |
| | | return false |
| | | } |
| | | |
| | | const getOpts = ({ target }: Event): { url: URL, scroll?: boolean } | undefined => { |
| | | if (!isElement(target)) return |
| | | const a = target.closest("a") |
| | | if (!a) return |
| | | if ('routerIgnore' in a.dataset) return |
| | | const { href } = a |
| | | if (!isLocalUrl(href)) return |
| | | return { url: new URL(href), scroll: 'routerNoscroll' in a.dataset ? false : undefined } |
| | | const isSamePage = (url: URL): boolean => { |
| | | const sameOrigin = url.origin === window.location.origin |
| | | const samePath = url.pathname === window.location.pathname |
| | | return sameOrigin && samePath |
| | | } |
| | | |
| | | function notifyNav(url: string) { |
| | | const getOpts = ({ target }: Event): { url: URL; scroll?: boolean } | undefined => { |
| | | if (!isElement(target)) return |
| | | if (target.attributes.getNamedItem("target")?.value === "_blank") return |
| | | const a = target.closest("a") |
| | | if (!a) return |
| | | if ("routerIgnore" in a.dataset) return |
| | | const { href } = a |
| | | if (!isLocalUrl(href)) return |
| | | return { url: new URL(href), scroll: "routerNoscroll" in a.dataset ? false : undefined } |
| | | } |
| | | |
| | | function notifyNav(url: FullSlug) { |
| | | const event: CustomEventMap["nav"] = new CustomEvent("nav", { detail: { url } }) |
| | | document.dispatchEvent(event) |
| | | } |
| | | |
| | | const cleanupFns: Set<(...args: any[]) => void> = new Set() |
| | | window.addCleanup = (fn) => cleanupFns.add(fn) |
| | | |
| | | function startLoading() { |
| | | const loadingBar = document.createElement("div") |
| | | loadingBar.className = "navigation-progress" |
| | | loadingBar.style.width = "0" |
| | | if (!document.body.contains(loadingBar)) { |
| | | document.body.appendChild(loadingBar) |
| | | } |
| | | |
| | | setTimeout(() => { |
| | | loadingBar.style.width = "80%" |
| | | }, 100) |
| | | } |
| | | |
| | | let p: DOMParser |
| | | async function navigate(url: URL, isBack: boolean = false) { |
| | | startLoading() |
| | | p = p || new DOMParser() |
| | | const contents = await fetch(`${url}`) |
| | | .then((res) => res.text()) |
| | | const contents = await fetchCanonical(url) |
| | | .then((res) => { |
| | | const contentType = res.headers.get("content-type") |
| | | if (contentType?.startsWith("text/html")) { |
| | | return res.text() |
| | | } else { |
| | | window.location.assign(url) |
| | | } |
| | | }) |
| | | .catch(() => { |
| | | window.location.assign(url) |
| | | }) |
| | | if (!contents) return; |
| | | if (!isBack) { |
| | | history.pushState({}, "", url) |
| | | window.scrollTo({ top: 0 }) |
| | | } |
| | | |
| | | if (!contents) return |
| | | |
| | | // cleanup old |
| | | cleanupFns.forEach((fn) => fn()) |
| | | cleanupFns.clear() |
| | | |
| | | const html = p.parseFromString(contents, "text/html") |
| | | normalizeRelativeURLs(html, url) |
| | | |
| | | let title = html.querySelector("title")?.textContent |
| | | if (title) { |
| | | document.title = title |
| | | } else { |
| | | const h1 = document.querySelector('h1') |
| | | const h1 = document.querySelector("h1") |
| | | title = h1?.innerText ?? h1?.textContent ?? url.pathname |
| | | } |
| | | if (announcer.textContent !== title) { |
| | | announcer.textContent = title |
| | | } |
| | | announcer.dataset.persist = '' |
| | | announcer.dataset.persist = "" |
| | | html.body.appendChild(announcer) |
| | | |
| | | // morph body |
| | | micromorph(document.body, html.body) |
| | | |
| | | // now, patch head |
| | | const elementsToRemove = document.head.querySelectorAll(':not([spa-preserve])') |
| | | elementsToRemove.forEach(el => el.remove()) |
| | | const elementsToAdd = html.head.querySelectorAll(':not([spa-preserve])') |
| | | elementsToAdd.forEach(el => document.head.appendChild(el)) |
| | | // scroll into place and add history |
| | | if (!isBack) { |
| | | if (url.hash) { |
| | | const el = document.getElementById(decodeURIComponent(url.hash.substring(1))) |
| | | el?.scrollIntoView() |
| | | } else { |
| | | window.scrollTo({ top: 0 }) |
| | | } |
| | | } |
| | | |
| | | notifyNav(document.body.dataset.slug!) |
| | | // now, patch head |
| | | const elementsToRemove = document.head.querySelectorAll(":not([spa-preserve])") |
| | | elementsToRemove.forEach((el) => el.remove()) |
| | | const elementsToAdd = html.head.querySelectorAll(":not([spa-preserve])") |
| | | elementsToAdd.forEach((el) => document.head.appendChild(el)) |
| | | |
| | | // delay setting the url until now |
| | | // at this point everything is loaded so changing the url should resolve to the correct addresses |
| | | if (!isBack) { |
| | | history.pushState({}, "", url) |
| | | } |
| | | |
| | | notifyNav(getFullSlug(window)) |
| | | delete announcer.dataset.persist |
| | | } |
| | | |
| | |
| | | if (typeof window !== "undefined") { |
| | | window.addEventListener("click", async (event) => { |
| | | const { url } = getOpts(event) ?? {} |
| | | if (!url) return |
| | | // dont hijack behaviour, just let browser act normally |
| | | if (!url || event.ctrlKey || event.metaKey) return |
| | | event.preventDefault() |
| | | |
| | | if (isSamePage(url) && url.hash) { |
| | | const el = document.getElementById(decodeURIComponent(url.hash.substring(1))) |
| | | el?.scrollIntoView() |
| | | history.pushState({}, "", url) |
| | | return |
| | | } |
| | | |
| | | try { |
| | | navigate(url, false) |
| | | } catch (e) { |
| | |
| | | } |
| | | }) |
| | | |
| | | window.addEventListener("popstate", () => { |
| | | if (window.location.hash) return |
| | | window.addEventListener("popstate", (event) => { |
| | | const { url } = getOpts(event) ?? {} |
| | | if (window.location.hash && window.location.pathname === url?.pathname) return |
| | | try { |
| | | navigate(new URL(window.location.toString()), true) |
| | | } catch (e) { |
| | |
| | | }) |
| | | } |
| | | |
| | | return new class Router { |
| | | go(pathname: string) { |
| | | return new (class Router { |
| | | go(pathname: RelativeURL) { |
| | | const url = new URL(pathname, window.location.toString()) |
| | | return navigate(url, false) |
| | | } |
| | |
| | | forward() { |
| | | return window.history.forward() |
| | | } |
| | | } |
| | | })() |
| | | } |
| | | |
| | | createRouter() |
| | | notifyNav(document.body.dataset.slug!) |
| | | notifyNav(getFullSlug(window)) |
| | | |
| | | if (!customElements.get('route-announcer')) { |
| | | if (!customElements.get("route-announcer")) { |
| | | const attrs = { |
| | | 'aria-live': 'assertive', |
| | | 'aria-atomic': 'true', |
| | | 'style': 'position: absolute; left: 0; top: 0; clip: rect(0 0 0 0); clip-path: inset(50%); overflow: hidden; white-space: nowrap; width: 1px; height: 1px' |
| | | "aria-live": "assertive", |
| | | "aria-atomic": "true", |
| | | style: |
| | | "position: absolute; left: 0; top: 0; clip: rect(0 0 0 0); clip-path: inset(50%); overflow: hidden; white-space: nowrap; width: 1px; height: 1px", |
| | | } |
| | | customElements.define('route-announcer', class RouteAnnouncer extends HTMLElement { |
| | | constructor() { |
| | | super() |
| | | } |
| | | connectedCallback() { |
| | | for (const [key, value] of Object.entries(attrs)) { |
| | | this.setAttribute(key, value) |
| | | |
| | | customElements.define( |
| | | "route-announcer", |
| | | class RouteAnnouncer extends HTMLElement { |
| | | constructor() { |
| | | super() |
| | | } |
| | | } |
| | | }) |
| | | connectedCallback() { |
| | | for (const [key, value] of Object.entries(attrs)) { |
| | | this.setAttribute(key, value) |
| | | } |
| | | } |
| | | }, |
| | | ) |
| | | } |