Compare commits
8 commits
Author | SHA1 | Date | |
---|---|---|---|
|
7fa79b3761 | ||
|
a495e2fe58 | ||
|
c26fee5287 | ||
|
4deaf08c86 | ||
|
ae533f5897 | ||
|
9ef255bdce | ||
|
979249b71a | ||
|
76653d991f |
7 changed files with 188 additions and 0 deletions
5
.changeset/nice-eels-thank.md
Normal file
5
.changeset/nice-eels-thank.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'astro': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Enable experimental SPA router
|
|
@ -7,4 +7,7 @@ import sitemap from '@astrojs/sitemap';
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
site: 'https://example.com',
|
site: 'https://example.com',
|
||||||
integrations: [mdx(), sitemap()],
|
integrations: [mdx(), sitemap()],
|
||||||
|
experimental: {
|
||||||
|
router: 'spa'
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -146,6 +146,7 @@
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"kleur": "^4.1.4",
|
"kleur": "^4.1.4",
|
||||||
"magic-string": "^0.27.0",
|
"magic-string": "^0.27.0",
|
||||||
|
"micromorph": "^0.4.5",
|
||||||
"mime": "^3.0.0",
|
"mime": "^3.0.0",
|
||||||
"ora": "^6.1.0",
|
"ora": "^6.1.0",
|
||||||
"path-to-regexp": "^6.2.1",
|
"path-to-regexp": "^6.2.1",
|
||||||
|
|
|
@ -43,6 +43,7 @@ const ASTRO_CONFIG_DEFAULTS: AstroUserConfig & any = {
|
||||||
customClientDirectives: false,
|
customClientDirectives: false,
|
||||||
inlineStylesheets: 'never',
|
inlineStylesheets: 'never',
|
||||||
middleware: false,
|
middleware: false,
|
||||||
|
router: 'mpa'
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -209,6 +210,10 @@ export const AstroConfigSchema = z.object({
|
||||||
.default(ASTRO_CONFIG_DEFAULTS.experimental.inlineStylesheets),
|
.default(ASTRO_CONFIG_DEFAULTS.experimental.inlineStylesheets),
|
||||||
middleware: z.oboolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.middleware),
|
middleware: z.oboolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.middleware),
|
||||||
hybridOutput: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.hybridOutput),
|
hybridOutput: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.hybridOutput),
|
||||||
|
router: z
|
||||||
|
.enum(['mpa', 'spa'])
|
||||||
|
.optional()
|
||||||
|
.default(ASTRO_CONFIG_DEFAULTS.experimental.router),
|
||||||
})
|
})
|
||||||
.passthrough()
|
.passthrough()
|
||||||
.refine(
|
.refine(
|
||||||
|
|
|
@ -60,6 +60,10 @@ export async function runHookConfigSetup({
|
||||||
let updatedSettings: AstroSettings = { ...settings, config: updatedConfig };
|
let updatedSettings: AstroSettings = { ...settings, config: updatedConfig };
|
||||||
let addedClientDirectives = new Map<string, Promise<string>>();
|
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) {
|
for (const integration of settings.config.integrations) {
|
||||||
/**
|
/**
|
||||||
* By making integration hooks optional, Astro can now ignore null or undefined Integrations
|
* By making integration hooks optional, Astro can now ignore null or undefined Integrations
|
||||||
|
|
163
packages/astro/src/runtime/client/router.ts
Normal file
163
packages/astro/src/runtime/client/router.ts
Normal 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();
|
7
pnpm-lock.yaml
generated
7
pnpm-lock.yaml
generated
|
@ -633,6 +633,9 @@ importers:
|
||||||
magic-string:
|
magic-string:
|
||||||
specifier: ^0.27.0
|
specifier: ^0.27.0
|
||||||
version: 0.27.0
|
version: 0.27.0
|
||||||
|
micromorph:
|
||||||
|
specifier: ^0.4.5
|
||||||
|
version: 0.4.5
|
||||||
mime:
|
mime:
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.0.0
|
version: 3.0.0
|
||||||
|
@ -14250,6 +14253,10 @@ packages:
|
||||||
braces: 3.0.2
|
braces: 3.0.2
|
||||||
picomatch: 2.3.1
|
picomatch: 2.3.1
|
||||||
|
|
||||||
|
/micromorph@0.4.5:
|
||||||
|
resolution: {integrity: sha512-Erasr0xiDvDeEhh7B/k7RFTwwfaAX10D7BMorNpokkwDh6XsRLYWDPaWF1m5JQeMSkGdqlEtQ8s68NcdDWuGgw==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/mime-db@1.52.0:
|
/mime-db@1.52.0:
|
||||||
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
|
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
Loading…
Add table
Reference in a new issue