From 3a29f4c86ee7ed13fb8683b2728a856581e32de7 Mon Sep 17 00:00:00 2001 From: Jacky Zhao <j.zhao2k19@gmail.com> Date: Fri, 9 Jun 2023 19:58:58 -0700 Subject: [PATCH] add custom spa solution --- quartz/bootstrap-cli.mjs | 4 + quartz/components/Head.tsx | 6 +- quartz/components/scripts/spa.inline.ts | 133 +++++++++++++++++++++++- quartz/plugins/index.ts | 1 + 4 files changed, 138 insertions(+), 6 deletions(-) diff --git a/quartz/bootstrap-cli.mjs b/quartz/bootstrap-cli.mjs index e9cda4e..e6bb3f0 100755 --- a/quartz/bootstrap-cli.mjs +++ b/quartz/bootstrap-cli.mjs @@ -71,6 +71,10 @@ yargs(hideBin(process.argv)) setup(build) { build.onLoad({ filter: /\.inline\.(ts|js)$/ }, async (args) => { let text = await promises.readFile(args.path, 'utf8') + // remove default exports that we manually inserted + text = text.replace('export default', '') + text = text.replace('export', '') + const transpiled = await esbuild.build({ stdin: { contents: text, diff --git a/quartz/components/Head.tsx b/quartz/components/Head.tsx index c56b8cb..37c0aba 100644 --- a/quartz/components/Head.tsx +++ b/quartz/components/Head.tsx @@ -9,7 +9,7 @@ export default function Head({ fileData, externalResources }: QuartzComponentPro const baseDir = resolveToRoot(slug) const iconPath = baseDir + "/static/icon.png" const ogImagePath = baseDir + "/static/og-image.png" - + return <head> <title>{title}</title> <meta charSet="utf-8" /> @@ -24,7 +24,7 @@ export default function Head({ fileData, externalResources }: QuartzComponentPro <meta name="generator" content="Quartz" /> <link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.gstatic.com" /> - {css.map(href => <link key={href} href={href} rel="stylesheet" type="text/css" />)} - {js.filter(resource => resource.loadTime === "beforeDOMReady").map(resource => <script key={resource.src} {...resource} />)} + {css.map(href => <link key={href} href={href} rel="stylesheet" type="text/css" spa-preserve />)} + {js.filter(resource => resource.loadTime === "beforeDOMReady").map(resource => <script key={resource.src} {...resource} spa-preserve />)} </head> } diff --git a/quartz/components/scripts/spa.inline.ts b/quartz/components/scripts/spa.inline.ts index 4020c23..2063a15 100644 --- a/quartz/components/scripts/spa.inline.ts +++ b/quartz/components/scripts/spa.inline.ts @@ -1,3 +1,130 @@ -import flamethrower from 'flamethrower-router' -const router = flamethrower() -export default "SpaScript" +import micromorph from "micromorph" + +// 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 +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) { } + 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 } +} + +let p: DOMParser +async function navigate(url: URL, isBack: boolean = false) { + p = p || new DOMParser() + const contents = await fetch(`${url}`) + .then((res) => res.text()) + .catch(() => { + window.location.assign(url) + }) + if (!contents) return; + if (!isBack) { + history.pushState({}, "", url) + window.scrollTo({ top: 0 }) + } + const html = p.parseFromString(contents, "text/html") + let title = html.querySelector("title")?.textContent + if (title) { + document.title = title + } else { + const h1 = document.querySelector('h1') + title = h1?.innerText ?? h1?.textContent ?? url.pathname + } + if (announcer.textContent !== title) { + announcer.textContent = title + } + announcer.dataset.persist = '' + html.body.appendChild(announcer) + + 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)) + + if (!document.activeElement?.closest('[data-persist]')) { + document.body.focus() + } + delete announcer.dataset.persist +} + +function createRouter() { + if (typeof window !== "undefined") { + window.addEventListener("click", async (event) => { + const { url } = getOpts(event) ?? {} + if (!url) return + event.preventDefault() + try { + navigate(url, false) + } catch (e) { + window.location.assign(url) + } + }) + + window.addEventListener("popstate", () => { + if (window.location.hash) return + try { + navigate(new URL(window.location.toString()), true) + } catch (e) { + window.location.reload() + } + return + }) + } + return new class Router { + go(pathname: string) { + const url = new URL(pathname, window.location.toString()) + return navigate(url, false) + } + + back() { + return window.history.back() + } + + forward() { + return window.history.forward() + } + } +} + +createRouter() + +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' + } + customElements.define('route-announcer', class RouteAnnouncer extends HTMLElement { + constructor() { + super() + } + connectedCallback() { + for (const [key, value] of Object.entries(attrs)) { + this.setAttribute(key, value) + } + } + }) +} diff --git a/quartz/plugins/index.ts b/quartz/plugins/index.ts index 6d5d840..32f8bc7 100644 --- a/quartz/plugins/index.ts +++ b/quartz/plugins/index.ts @@ -4,6 +4,7 @@ import { StaticResources } from '../resources' import { googleFontHref, joinStyles } from '../theme' import { EmitCallback, PluginTypes } from './types' import styles from '../styles/base.scss' +// @ts-ignore import spaRouterScript from '../components/scripts/spa.inline' export type ComponentResources = {