Compare commits

...

8 commits

Author SHA1 Message Date
Nate Moore
7fa79b3761
Merge branch 'main' into feat/csr 2023-05-22 10:55:02 -05:00
Nate Moore
a495e2fe58 chore(lint): remove type annotation 2023-05-19 16:13:21 -05:00
Nate Moore
c26fee5287 fix: type errors 2023-05-19 16:11:44 -05:00
Nate Moore
4deaf08c86 chore: add changeset 2023-05-19 16:08:25 -05:00
Nate Moore
ae533f5897 chore: update lockfile 2023-05-19 16:07:38 -05:00
Nate Moore
9ef255bdce feat(spa): custom router 2023-05-19 16:01:24 -05:00
Nate Moore
979249b71a chore: update micromorph 2023-05-19 16:01:23 -05:00
Nate Moore
76653d991f initial commit 2023-05-19 16:01:22 -05:00
7 changed files with 188 additions and 0 deletions

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Enable experimental SPA router

View file

@ -7,4 +7,7 @@ import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://example.com',
integrations: [mdx(), sitemap()],
experimental: {
router: 'spa'
}
});

View file

@ -146,6 +146,7 @@
"js-yaml": "^4.1.0",
"kleur": "^4.1.4",
"magic-string": "^0.27.0",
"micromorph": "^0.4.5",
"mime": "^3.0.0",
"ora": "^6.1.0",
"path-to-regexp": "^6.2.1",

View file

@ -43,6 +43,7 @@ const ASTRO_CONFIG_DEFAULTS: AstroUserConfig & any = {
customClientDirectives: false,
inlineStylesheets: 'never',
middleware: false,
router: 'mpa'
},
};
@ -209,6 +210,10 @@ export const AstroConfigSchema = z.object({
.default(ASTRO_CONFIG_DEFAULTS.experimental.inlineStylesheets),
middleware: z.oboolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.middleware),
hybridOutput: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.hybridOutput),
router: z
.enum(['mpa', 'spa'])
.optional()
.default(ASTRO_CONFIG_DEFAULTS.experimental.router),
})
.passthrough()
.refine(

View file

@ -59,6 +59,10 @@ export async function runHookConfigSetup({
let updatedConfig: AstroConfig = { ...settings.config };
let updatedSettings: AstroSettings = { ...settings, config: updatedConfig };
let addedClientDirectives = new Map<string, Promise<string>>();
if (settings.config.experimental.router === 'spa') {
updatedSettings.scripts.push({ stage: 'page', content: 'import "astro/client/router.js"' });
}
for (const integration of settings.config.integrations) {
/**

View file

@ -0,0 +1,163 @@
import micromorph from "micromorph";
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);
}
}
})
}
let announcer = document.createElement('route-announcer');
const updateRelativeURL = (el: Element, attr: string, base: string | URL) => {
el.setAttribute(attr, new URL(el.getAttribute(attr)!, base).pathname);
};
function normalizeRelativeURLs(
el: Element | Document,
base: string | URL
) {
el.querySelectorAll('[href^="./"], [href^="../"]').forEach((item) =>
updateRelativeURL(item, 'href', base)
);
el.querySelectorAll('[src^="./"], [src^="../"]').forEach((item) =>
updateRelativeURL(item, 'src', base)
);
}
const isElement = (target: EventTarget | null): target is Element => (target as Node)?.nodeType === (target as Node).ELEMENT_NODE;
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, opts: Options): { url: URL } & Required<Options> | 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 : true, focus: 'routerKeepfocus' in a.dataset ? false : true };
};
let p: DOMParser;
async function setup(doc: Document) {
for (const island of doc.querySelectorAll('astro-island')) {
const uid = island.getAttribute('uid');
const current = document.querySelector<HTMLElement>(`astro-island[uid="${uid}"]`);
if (current) {
current.dataset.persist = 'true';
island.replaceWith(current);
}
}
}
async function teardown() {
for (const island of document.querySelectorAll<HTMLElement>('astro-island')) {
delete island.dataset.persist;
}
window.dispatchEvent(new CustomEvent('astro:hydrate'));
}
async function navigate(url: URL, isBack = false, opts: Options) {
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);
if (opts.scroll ?? true) {
window.scrollTo({ top: 0 });
}
}
const html = p.parseFromString(contents, "text/html");
normalizeRelativeURLs(html, url);
await setup(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);
if ((document as any).startViewTransition) {
await (document as any).startViewTransition(() => micromorph(document, html))
} else {
await micromorph(document, html);
}
if (!document.activeElement?.closest('[data-persist]')) {
document.body.focus();
}
await teardown();
delete announcer.dataset.persist;
}
interface Options {
scroll?: boolean;
focus?: boolean;
}
function createRouter(opts: Options = {}) {
if (typeof window !== "undefined") {
window.addEventListener("click", async (event) => {
const { url, ...options } = getOpts(event, opts) ?? {};
if (!url) return;
event.preventDefault();
try {
await navigate(url, false, { ...opts, ...options });
} catch (e) {
window.location.assign(url);
}
});
window.addEventListener("popstate", () => {
if (window.location.hash) return;
try {
navigate(new URL(window.location.toString()), true, opts);
} catch (e) {
window.location.reload();
}
return;
});
}
return new class Router {
go(pathname: string, options?: Partial<Options>) {
const url = new URL(pathname, window.location.toString())
return navigate(url, false, { ...opts, ...options })
}
back() {
return window.history.back();
}
forward() {
return window.history.forward();
}
}
}
createRouter();

View file

@ -633,6 +633,9 @@ importers:
magic-string:
specifier: ^0.27.0
version: 0.27.0
micromorph:
specifier: ^0.4.5
version: 0.4.5
mime:
specifier: ^3.0.0
version: 3.0.0
@ -14250,6 +14253,10 @@ packages:
braces: 3.0.2
picomatch: 2.3.1
/micromorph@0.4.5:
resolution: {integrity: sha512-Erasr0xiDvDeEhh7B/k7RFTwwfaAX10D7BMorNpokkwDh6XsRLYWDPaWF1m5JQeMSkGdqlEtQ8s68NcdDWuGgw==}
dev: false
/mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}