Organized caching + groundwork for page caching

This commit is contained in:
DrMint 2024-06-27 22:04:08 +02:00
parent d9ef48d811
commit 2cacccae86
23 changed files with 392 additions and 351 deletions

View File

@ -10,7 +10,6 @@
## Short term ## Short term
- [Bugs] On android Chrome, the setting button in the header flashes for a few ms when the page is loading - [Bugs] On android Chrome, the setting button in the header flashes for a few ms when the page is loading
- [Feat] [caching] Use getURLs for precaching + precache everything
- [Bugs] Make sure uploads name are slug-like and with an extension. - [Bugs] Make sure uploads name are slug-like and with an extension.
- [Bugs] Nyupun can't upload subtitles files - [Bugs] Nyupun can't upload subtitles files
- [Bugs] https://v3.accords-library.com/en/collectibles/dod-original-soundtrack/scans obi is way too big - [Bugs] https://v3.accords-library.com/en/collectibles/dod-original-soundtrack/scans obi is way too big

58
src/cache/contextCache.ts vendored Normal file
View File

@ -0,0 +1,58 @@
import type {
EndpointWebsiteConfig,
EndpointWording,
Language,
} from "src/shared/payload/payload-sdk";
import { getLogger } from "src/utils/logger";
import { payload } from "src/utils/payload";
class ContextCache {
private initialized = false;
private logger = getLogger("[ContextCache]");
locales: Language[] = [];
currencies: string[] = [];
wordings: EndpointWording[] = [];
config: EndpointWebsiteConfig = {
home: { folders: [] },
timeline: { breaks: [], eras: [], eventCount: 0 },
};
async init() {
if (this.initialized) return;
await this.refreshAll();
this.initialized = true;
this.logger.log("Init complete");
}
private async refreshAll() {
await this.refreshCurrencies();
await this.refreshLocales();
await this.refreshWebsiteConfig();
await this.refreshWordings();
}
async refreshWordings() {
this.wordings = await payload.getWordings();
this.logger.log("Wordings refreshed");
}
async refreshCurrencies() {
contextCache.currencies = (await payload.getCurrencies()).map(({ id }) => id);
this.logger.log("Currencies refreshed");
}
async refreshLocales() {
contextCache.locales = await payload.getLanguages();
this.logger.log("Locales refreshed");
}
async refreshWebsiteConfig() {
contextCache.config = await payload.getConfig();
this.logger.log("WebsiteConfig refreshed");
}
}
const contextCache = new ContextCache();
await contextCache.init();
export { contextCache };

84
src/cache/dataCache.ts vendored Normal file
View File

@ -0,0 +1,84 @@
import { getLogger } from "src/utils/logger";
import { payload } from "src/utils/payload";
class DataCache {
private readonly logger = getLogger("[DataCache]");
private initialized = false;
private readonly responseCache = new Map<string, any>();
private readonly idsCacheMap = new Map<string, Set<string>>();
async init() {
if (this.initialized) return;
if (import.meta.env.ENABLE_PRECACHING === "true") {
await this.precache();
}
this.initialized = true;
}
private async precache() {
const { urls } = await payload.getAllSdkUrls();
for (const url of urls) {
try {
await payload.request(url);
} catch {
this.logger.warn("Precaching failed for url", url);
}
}
this.logger.log("Precaching completed!", this.responseCache.size, "responses cached");
}
get(url: string) {
const cachedResponse = this.responseCache.get(url);
if (cachedResponse) {
this.logger.log("Retrieved cached response for", url);
return structuredClone(cachedResponse);
}
}
set(url: string, response: any) {
const stringData = JSON.stringify(response);
const regex = /[a-f0-9]{24}/g;
const ids = [...stringData.matchAll(regex)].map((match) => match[0]);
const uniqueIds = [...new Set(ids)];
uniqueIds.forEach((id) => {
const current = this.idsCacheMap.get(id);
if (current) {
current.add(url);
} else {
this.idsCacheMap.set(id, new Set([url]));
}
});
this.responseCache.set(url, response);
this.logger.log("Cached response for", url);
}
async invalidate(ids: string[], urls: string[]) {
const urlsToInvalidate = new Set<string>(urls);
ids.forEach((id) => {
const urlsForThisId = this.idsCacheMap.get(id);
if (!urlsForThisId) return;
this.idsCacheMap.delete(id);
[...urlsForThisId].forEach((url) => urlsToInvalidate.add(url));
});
for (const url of urlsToInvalidate) {
this.responseCache.delete(url);
this.logger.log("Invalidated cache for", url);
try {
await payload.request(url);
} catch (e) {
this.logger.log("Revalidation fails for", url);
}
}
this.logger.log("There are currently", this.responseCache.size, "responses in cache.");
}
}
export const dataCache = new DataCache();

View File

@ -7,7 +7,7 @@ import type {
EndpointPayloadImage, EndpointPayloadImage,
EndpointVideo, EndpointVideo,
} from "src/shared/payload/payload-sdk"; } from "src/shared/payload/payload-sdk";
import { cache } from "src/utils/payload"; import { contextCache } from "src/cache/contextCache";
interface Props { interface Props {
openGraph?: openGraph?:
@ -28,7 +28,9 @@ const { openGraph = {} } = Astro.props;
const { description = t("global.meta.description"), audio, video } = openGraph; const { description = t("global.meta.description"), audio, video } = openGraph;
const thumbnail = const thumbnail =
openGraph.thumbnail?.openGraph ?? openGraph.thumbnail ?? cache.config.defaultOpenGraphImage; openGraph.thumbnail?.openGraph ??
openGraph.thumbnail ??
contextCache.config.defaultOpenGraphImage;
const title = openGraph.title const title = openGraph.title
? `${openGraph.title} ${t("global.siteName")}` ? `${openGraph.title} ${t("global.siteName")}`
@ -38,8 +40,6 @@ const userAgent = Astro.request.headers.get("user-agent") ?? "";
const parser = new UAParser(userAgent); const parser = new UAParser(userAgent);
const isIOS = parser.getOS().name === "iOS"; const isIOS = parser.getOS().name === "iOS";
const { currentTheme } = Astro.locals;
/* Keep that separator here or else it breaks the HTML /* Keep that separator here or else it breaks the HTML
----------------------------------------------- HTML -------------------------------------------- */ ----------------------------------------------- HTML -------------------------------------------- */
--- ---
@ -47,9 +47,7 @@ const { currentTheme } = Astro.locals;
<html <html
lang={currentLocale} lang={currentLocale}
class:list={{ class:list={{
"manual-theme": currentTheme !== "auto", POST_PROCESS_HTML_CLASS: true,
"light-theme": currentTheme === "light",
"dark-theme": currentTheme === "dark",
"texture-dots": !isIOS, "texture-dots": !isIOS,
"font-m": true, "font-m": true,
"debug-lang": false, "debug-lang": false,
@ -310,7 +308,7 @@ const { currentTheme } = Astro.locals;
color: var(--color-base-1000); color: var(--color-base-1000);
} }
html:not(.manual-theme) { html:not(.light-theme, .dark-theme) {
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
& .when-dark-theme { & .when-dark-theme {
display: none !important; display: none !important;

View File

@ -2,7 +2,7 @@
import Button from "components/Button.astro"; import Button from "components/Button.astro";
import Tooltip from "components/Tooltip.astro"; import Tooltip from "components/Tooltip.astro";
import { getI18n } from "src/i18n/i18n"; import { getI18n } from "src/i18n/i18n";
import { cache } from "src/utils/payload"; import { contextCache } from "src/cache/contextCache";
import { formatCurrency } from "src/utils/currencies"; import { formatCurrency } from "src/utils/currencies";
interface Props { interface Props {
@ -21,14 +21,13 @@ const { currentCurrency } = Astro.locals;
<Tooltip trigger="click" {...otherProps.class ? otherProps : {}}> <Tooltip trigger="click" {...otherProps.class ? otherProps : {}}>
<div id="content" slot="tooltip-content"> <div id="content" slot="tooltip-content">
{ {
cache.currencies.map((id) => ( contextCache.currencies.map((id) => (
<a <a
class:list={{ class:list={{
current: currentCurrency === id, current: currentCurrency === id,
"pressable-link": true, "pressable-link": true,
}} }}
href={`?action-currency=${id}`} href={`?action-currency=${id}`}>
data-astro-prefetch="tap">
{`${id} (${formatCurrency(id)})`} {`${id} (${formatCurrency(id)})`}
</a> </a>
)) ))

View File

@ -2,7 +2,7 @@
import Button from "components/Button.astro"; import Button from "components/Button.astro";
import Tooltip from "components/Tooltip.astro"; import Tooltip from "components/Tooltip.astro";
import { getI18n } from "src/i18n/i18n"; import { getI18n } from "src/i18n/i18n";
import { cache } from "src/utils/payload"; import { contextCache } from "src/cache/contextCache";
import { formatLocale } from "src/utils/format"; import { formatLocale } from "src/utils/format";
interface Props { interface Props {
@ -21,11 +21,10 @@ const { t } = await getI18n(currentLocale);
<Tooltip trigger="click" {...otherProps.class ? otherProps : {}}> <Tooltip trigger="click" {...otherProps.class ? otherProps : {}}>
<div id="content" slot="tooltip-content"> <div id="content" slot="tooltip-content">
{ {
cache.locales.map(({ id }) => ( contextCache.locales.map(({ id }) => (
<a <a
class:list={{ current: currentLocale === id, "pressable-link": true }} class:list={{ current: currentLocale === id, "pressable-link": true }}
href={`?action-lang=${id}`} href={`?action-lang=${id}`}>
data-astro-prefetch="tap">
{formatLocale(id)} {formatLocale(id)}
</a> </a>
)) ))

View File

@ -3,7 +3,7 @@ import Button from "components/Button.astro";
import Tooltip from "components/Tooltip.astro"; import Tooltip from "components/Tooltip.astro";
import { getI18n } from "src/i18n/i18n"; import { getI18n } from "src/i18n/i18n";
const { currentLocale, currentTheme } = Astro.locals; const { currentLocale } = Astro.locals;
const { t } = await getI18n(currentLocale); const { t } = await getI18n(currentLocale);
--- ---
@ -11,19 +11,13 @@ const { t } = await getI18n(currentLocale);
<Tooltip trigger="click"> <Tooltip trigger="click">
<div id="content" slot="tooltip-content"> <div id="content" slot="tooltip-content">
<a <a class="pressable-link underline-when-dark" href="?action-theme=dark">
class:list={{ current: currentTheme === "dark", "pressable-link": true }}
href="?action-theme=dark">
{t("global.theme.dark")} {t("global.theme.dark")}
</a> </a>
<a <a class="pressable-link underline-when-auto" href="?action-theme=auto">
class:list={{ current: currentTheme === "auto", "pressable-link": true }}
href="?action-theme=auto">
{t("global.theme.auto")} {t("global.theme.auto")}
</a> </a>
<a <a class="pressable-link underline-when-light" href="?action-theme=light">
class:list={{ current: currentTheme === "light", "pressable-link": true }}
href="?action-theme=light">
{t("global.theme.light")} {t("global.theme.light")}
</a> </a>
</div> </div>
@ -45,10 +39,12 @@ const { t } = await getI18n(currentLocale);
#content { #content {
display: grid; display: grid;
gap: 0.5em; gap: 0.5em;
}
& > .current { :global(html.light-theme) a.underline-when-light,
color: var(--color-base-750); :global(html.dark-theme) a.underline-when-dark,
text-decoration: underline 0.08em var(--color-base-650); :global(html:not(.light-theme, .dark-theme)) a.underline-when-auto {
} color: var(--color-base-750);
text-decoration: underline 0.08em var(--color-base-650);
} }
</style> </style>

1
src/env.d.ts vendored
View File

@ -4,7 +4,6 @@
declare namespace App { declare namespace App {
interface Locals { interface Locals {
currentLocale: string; currentLocale: string;
currentTheme: "dark" | "auto" | "light";
currentCurrency: string; currentCurrency: string;
notFound: boolean; notFound: boolean;
} }

View File

@ -1,6 +1,6 @@
import type { WordingKey } from "src/i18n/wordings-keys"; import type { WordingKey } from "src/i18n/wordings-keys";
import type { ChronologyEvent, EndpointSource } from "src/shared/payload/payload-sdk"; import type { ChronologyEvent, EndpointSource } from "src/shared/payload/payload-sdk";
import { cache } from "src/utils/payload"; import { contextCache } from "src/cache/contextCache";
import { capitalize, formatInlineTitle } from "src/utils/format"; import { capitalize, formatInlineTitle } from "src/utils/format";
export const defaultLocale = "en"; export const defaultLocale = "en";
@ -113,7 +113,7 @@ export const getI18n = async (locale: string) => {
options[0]!; // We will consider that there will always be at least one option. options[0]!; // We will consider that there will always be at least one option.
const t = (key: WordingKey, values: Record<string, any> = {}): string => { const t = (key: WordingKey, values: Record<string, any> = {}): string => {
const wording = cache.wordings.find(({ name }) => name === key); const wording = contextCache.wordings.find(({ name }) => name === key);
const fallbackString = `«${key}»`; const fallbackString = `«${key}»`;
if (!wording) { if (!wording) {

View File

@ -1,10 +1,10 @@
import { defineMiddleware, sequence } from "astro:middleware"; import { defineMiddleware, sequence } from "astro:middleware";
import { cache } from "src/utils/payload";
import acceptLanguage from "accept-language"; import acceptLanguage from "accept-language";
import type { AstroCookies } from "astro"; import type { AstroCookies } from "astro";
import { z } from "astro:content"; import { z } from "astro:content";
import { trackRequest, trackEvent } from "src/shared/analytics/analytics"; import { trackRequest, trackEvent } from "src/shared/analytics/analytics";
import { defaultLocale } from "src/i18n/i18n"; import { defaultLocale } from "src/i18n/i18n";
import { contextCache } from "src/cache/contextCache";
const ninetyDaysInSeconds = 60 * 60 * 24 * 90; const ninetyDaysInSeconds = 60 * 60 * 24 * 90;
@ -141,20 +141,22 @@ const refreshCookiesMaxAge = defineMiddleware(async ({ cookies }, next) => {
return response; return response;
}); });
const addContentLanguageResponseHeader = defineMiddleware(async ({ url }, next) => { const addCommonHeaders = defineMiddleware(async ({ url }, next) => {
const currentLocale = getCurrentLocale(url.pathname); const currentLocale = getCurrentLocale(url.pathname);
const response = await next(); const response = await next();
if (response.status === 200 && currentLocale) { if (response.ok && currentLocale) {
response.headers.set("Content-Language", currentLocale); response.headers.set("Content-Language", currentLocale);
} }
response.headers.set("Vary", "Cookie");
return response; return response;
}); });
const provideLocalsToRequest = defineMiddleware(async ({ url, locals, cookies }, next) => { const provideLocalsToRequest = defineMiddleware(async ({ url, locals, cookies }, next) => {
locals.currentLocale = getCurrentLocale(url.pathname) ?? "en"; locals.currentLocale = getCurrentLocale(url.pathname) ?? "en";
locals.currentCurrency = getCookieCurrency(cookies) ?? "USD"; locals.currentCurrency = getCookieCurrency(cookies) ?? "USD";
locals.currentTheme = getCookieTheme(cookies) ?? "auto";
return next(); return next();
}); });
@ -165,19 +167,40 @@ const analytics = defineMiddleware(async (context, next) => {
return response; return response;
}); });
const postProcess = defineMiddleware(async ({ cookies }, next) => {
const response = await next();
if (!response.ok) {
return response;
}
let html = await response.text();
const currentTheme = getCookieTheme(cookies) ?? "auto";
html = html.replace(
"POST_PROCESS_HTML_CLASS",
currentTheme === "dark" ? "dark-theme" : currentTheme === "light" ? "light-theme" : ""
);
return new Response(html, response);
});
export const onRequest = sequence( export const onRequest = sequence(
addContentLanguageResponseHeader, // Possible redirect
handleActionsSearchParams, handleActionsSearchParams,
refreshCookiesMaxAge,
localeNegotiator, localeNegotiator,
addCommonHeaders,
// Get a response
analytics,
refreshCookiesMaxAge,
provideLocalsToRequest, provideLocalsToRequest,
analytics postProcess
); );
/* LOCALE */ /* LOCALE */
const getCurrentLocale = (pathname: string): string | undefined => { const getCurrentLocale = (pathname: string): string | undefined => {
for (const locale of cache.locales) { for (const locale of contextCache.locales) {
if (pathname.split("/")[1] === locale.id) { if (pathname.split("/")[1] === locale.id) {
return locale.id; return locale.id;
} }
@ -189,7 +212,7 @@ const getBestAcceptedLanguage = (request: Request): string | undefined => {
const header = request.headers.get("Accept-Language"); const header = request.headers.get("Accept-Language");
if (!header) return; if (!header) return;
acceptLanguage.languages(cache.locales.map(({ id }) => id)); acceptLanguage.languages(contextCache.locales.map(({ id }) => id));
return acceptLanguage.get(request.headers.get("Accept-Language")) ?? undefined; return acceptLanguage.get(request.headers.get("Accept-Language")) ?? undefined;
}; };
@ -220,10 +243,12 @@ export const getCookieTheme = (cookies: AstroCookies): z.infer<typeof themeSchem
}; };
export const isValidCurrency = (currency: string | null | undefined): currency is string => export const isValidCurrency = (currency: string | null | undefined): currency is string =>
currency !== null && currency != undefined && cache.currencies.includes(currency); currency !== null && currency != undefined && contextCache.currencies.includes(currency);
export const isValidLocale = (locale: string | null | undefined): locale is string => export const isValidLocale = (locale: string | null | undefined): locale is string =>
locale !== null && locale != undefined && cache.locales.map(({ id }) => id).includes(locale); locale !== null &&
locale != undefined &&
contextCache.locales.map(({ id }) => id).includes(locale);
export const isValidTheme = ( export const isValidTheme = (
theme: string | null | undefined theme: string | null | undefined

View File

@ -1,7 +1,7 @@
--- ---
import LibraryCard from "./LibraryCard.astro"; import LibraryCard from "./LibraryCard.astro";
import { getI18n } from "src/i18n/i18n"; import { getI18n } from "src/i18n/i18n";
import { cache } from "src/utils/payload"; import { contextCache } from "src/cache/contextCache";
const { getLocalizedUrl, getLocalizedMatch } = await getI18n(Astro.locals.currentLocale); const { getLocalizedUrl, getLocalizedMatch } = await getI18n(Astro.locals.currentLocale);
--- ---
@ -9,7 +9,7 @@ const { getLocalizedUrl, getLocalizedMatch } = await getI18n(Astro.locals.curren
{/* ------------------------------------------- HTML ------------------------------------------- */} {/* ------------------------------------------- HTML ------------------------------------------- */}
{ {
cache.config.home.folders.map(({ slug, translations, darkThumbnail, lightThumbnail }) => ( contextCache.config.home.folders.map(({ slug, translations, darkThumbnail, lightThumbnail }) => (
<LibraryCard <LibraryCard
img={ img={
darkThumbnail && lightThumbnail ? { dark: darkThumbnail, light: lightThumbnail } : undefined darkThumbnail && lightThumbnail ? { dark: darkThumbnail, light: lightThumbnail } : undefined

View File

@ -1,12 +1,7 @@
import type { APIRoute } from "astro"; import type { APIRoute } from "astro";
import { contextCache } from "src/cache/contextCache";
import { dataCache } from "src/cache/dataCache";
import { Collections, type AfterOperationWebHookMessage } from "src/shared/payload/payload-sdk"; import { Collections, type AfterOperationWebHookMessage } from "src/shared/payload/payload-sdk";
import {
invalidateDataCache,
refreshCurrencies,
refreshLocales,
refreshWebsiteConfig,
refreshWordings,
} from "src/utils/payload";
export const POST: APIRoute = async ({ request }) => { export const POST: APIRoute = async ({ request }) => {
const auth = request.headers.get("Authorization"); const auth = request.headers.get("Authorization");
@ -30,23 +25,23 @@ const handleWebHookMessage = async ({
urls, urls,
id, id,
}: AfterOperationWebHookMessage) => { }: AfterOperationWebHookMessage) => {
await invalidateDataCache([...(id ? [id] : []), ...addedDependantIds], urls); await dataCache.invalidate([...(id ? [id] : []), ...addedDependantIds], urls);
switch (collection) { switch (collection) {
case Collections.Wordings: case Collections.Wordings:
await refreshWordings(); await contextCache.refreshWordings();
break; break;
case Collections.Currencies: case Collections.Currencies:
await refreshCurrencies(); await contextCache.refreshCurrencies();
break; break;
case Collections.Languages: case Collections.Languages:
await refreshLocales(); await contextCache.refreshLocales();
break; break;
case Collections.WebsiteConfig: case Collections.WebsiteConfig:
await refreshWebsiteConfig(); await contextCache.refreshWebsiteConfig();
break; break;
} }
}; };

View File

@ -1,7 +1,7 @@
import type { APIRoute } from "astro"; import type { APIRoute } from "astro";
import { initPayload } from "src/utils/payload"; import { dataCache } from "src/cache/dataCache";
export const GET: APIRoute = async () => { export const GET: APIRoute = async () => {
await initPayload(); await dataCache.init();
return new Response(null, { status: 200, statusText: "Ok" }); return new Response(null, { status: 200, statusText: "Ok" });
}; };

View File

@ -9,7 +9,7 @@ interface Props {
const { baseColors, theme } = Astro.props; const { baseColors, theme } = Astro.props;
--- ---
<div id="container" class:list={[theme, "manual-theme"]}> <div id="container" class:list={theme}>
<h4 class="font-xl">Base colors</h4> <h4 class="font-xl">Base colors</h4>
<div class="colors"> <div class="colors">
{ {

View File

@ -3,7 +3,7 @@ import Button from "components/Button.astro";
import LibraryGrid from "./_components/LibraryGrid.astro"; import LibraryGrid from "./_components/LibraryGrid.astro";
import LinkCard from "./_components/LinkCard.astro"; import LinkCard from "./_components/LinkCard.astro";
import { getI18n } from "src/i18n/i18n"; import { getI18n } from "src/i18n/i18n";
import { cache } from "src/utils/payload"; import { contextCache } from "src/cache/contextCache";
import AppLayout from "components/AppLayout/AppLayout.astro"; import AppLayout from "components/AppLayout/AppLayout.astro";
import HomeTitle from "./_components/HomeTitle.astro"; import HomeTitle from "./_components/HomeTitle.astro";
@ -14,7 +14,7 @@ const { t, getLocalizedUrl } = await getI18n(Astro.locals.currentLocale);
<AppLayout <AppLayout
openGraph={{ title: t("home.title") }} openGraph={{ title: t("home.title") }}
backgroundImage={cache.config.home.backgroundImage} backgroundImage={contextCache.config.home.backgroundImage}
hideFooterLinks hideFooterLinks
hideHomeButton hideHomeButton
class="app"> class="app">
@ -77,8 +77,8 @@ const { t, getLocalizedUrl } = await getI18n(Astro.locals.currentLocale);
icon="material-symbols:calendar-month" icon="material-symbols:calendar-month"
title={t("footer.links.timeline.title")} title={t("footer.links.timeline.title")}
subtitle={t("footer.links.timeline.subtitle", { subtitle={t("footer.links.timeline.subtitle", {
eraCount: cache.config.timeline.eras.length, eraCount: contextCache.config.timeline.eras.length,
eventCount: cache.config.timeline.eventCount, eventCount: contextCache.config.timeline.eventCount,
})} })}
href={getLocalizedUrl("/timeline")} href={getLocalizedUrl("/timeline")}
/> />

View File

@ -2,11 +2,11 @@
import AppLayout from "components/AppLayout/AppLayout.astro"; import AppLayout from "components/AppLayout/AppLayout.astro";
import AppLayoutTitle from "components/AppLayout/components/AppLayoutTitle.astro"; import AppLayoutTitle from "components/AppLayout/components/AppLayoutTitle.astro";
import { getI18n } from "src/i18n/i18n"; import { getI18n } from "src/i18n/i18n";
import { cache } from "src/utils/payload"; import { contextCache } from "src/cache/contextCache";
import { formatCurrency } from "src/utils/currencies"; import { formatCurrency } from "src/utils/currencies";
import { formatLocale } from "src/utils/format"; import { formatLocale } from "src/utils/format";
const { currentLocale, currentTheme, currentCurrency } = Astro.locals; const { currentLocale, currentCurrency } = Astro.locals;
const { t } = await getI18n(currentLocale); const { t } = await getI18n(currentLocale);
--- ---
@ -20,11 +20,10 @@ const { t } = await getI18n(currentLocale);
<h2>{t("settings.language.title")}</h2> <h2>{t("settings.language.title")}</h2>
<p>{t("settings.language.description")}</p><br /> <p>{t("settings.language.description")}</p><br />
{ {
cache.locales.map(({ id }) => ( contextCache.locales.map(({ id }) => (
<a <a
class:list={{ current: currentLocale === id, "pressable-link": true }} class:list={{ current: currentLocale === id, "pressable-link": true }}
href={`?action-lang=${id}`} href={`?action-lang=${id}`}>
data-astro-prefetch="tap">
{formatLocale(id)} {formatLocale(id)}
</a> </a>
)) ))
@ -34,22 +33,13 @@ const { t } = await getI18n(currentLocale);
<div class="section"> <div class="section">
<h2>{t("settings.theme.title")}</h2> <h2>{t("settings.theme.title")}</h2>
<p>{t("settings.theme.description")}</p><br /> <p>{t("settings.theme.description")}</p><br />
<a <a class="pressable-link underline-when-dark" href="?action-theme=dark">
class:list={{ current: currentTheme === "dark", "pressable-link": true }}
href="?action-theme=dark"
data-astro-prefetch="tap">
{t("global.theme.dark")} {t("global.theme.dark")}
</a> </a>
<a <a class="pressable-link underline-when-auto" href="?action-theme=auto">
class:list={{ current: currentTheme === "auto", "pressable-link": true }}
href="?action-theme=auto"
data-astro-prefetch="tap">
{t("global.theme.auto")} {t("global.theme.auto")}
</a> </a>
<a <a class="pressable-link underline-when-light" href="?action-theme=light">
class:list={{ current: currentTheme === "light", "pressable-link": true }}
href="?action-theme=light"
data-astro-prefetch="tap">
{t("global.theme.light")} {t("global.theme.light")}
</a> </a>
</div> </div>
@ -58,11 +48,10 @@ const { t } = await getI18n(currentLocale);
<h2>{t("settings.currency.title")}</h2> <h2>{t("settings.currency.title")}</h2>
<p>{t("settings.currency.description")}</p><br /> <p>{t("settings.currency.description")}</p><br />
{ {
cache.currencies.map((id) => ( contextCache.currencies.map((id) => (
<a <a
class:list={{ current: currentCurrency === id, "pressable-link": true }} class:list={{ current: currentCurrency === id, "pressable-link": true }}
href={`?action-currency=${id}`} href={`?action-currency=${id}`}>
data-astro-prefetch="tap">
{`${id} (${formatCurrency(id)})`} {`${id} (${formatCurrency(id)})`}
</a> </a>
)) ))
@ -86,6 +75,13 @@ const { t } = await getI18n(currentLocale);
} }
} }
:global(html.light-theme) a.underline-when-light,
:global(html.dark-theme) a.underline-when-dark,
:global(html:not(.light-theme, .dark-theme)) a.underline-when-auto {
color: var(--color-base-750);
text-decoration: underline 0.08em var(--color-base-650);
}
#main { #main {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));

View File

@ -2,7 +2,7 @@
import type { EndpointChronologyEvent } from "src/shared/payload/payload-sdk"; import type { EndpointChronologyEvent } from "src/shared/payload/payload-sdk";
import TimelineEvent from "./TimelineEvent.astro"; import TimelineEvent from "./TimelineEvent.astro";
import { getI18n } from "src/i18n/i18n"; import { getI18n } from "src/i18n/i18n";
import { cache } from "src/utils/payload"; import { contextCache } from "src/cache/contextCache";
interface Props { interface Props {
year: number; year: number;
@ -25,7 +25,7 @@ if (year === 856) {
{/* ------------------------------------------- HTML ------------------------------------------- */} {/* ------------------------------------------- HTML ------------------------------------------- */}
{cache.config.timeline.breaks.includes(year) && <hr id={`hr-${year}`} />} {contextCache.config.timeline.breaks.includes(year) && <hr id={`hr-${year}`} />}
<div> <div>
<h2 class="font-2xl" class:list={{ multiple }} id={year.toString()}> <h2 class="font-2xl" class:list={{ multiple }} id={year.toString()}>

View File

@ -6,7 +6,7 @@ import AppLayout from "components/AppLayout/AppLayout.astro";
import AppLayoutTitle from "components/AppLayout/components/AppLayoutTitle.astro"; import AppLayoutTitle from "components/AppLayout/components/AppLayoutTitle.astro";
import Card from "components/Card.astro"; import Card from "components/Card.astro";
import { getI18n } from "src/i18n/i18n"; import { getI18n } from "src/i18n/i18n";
import { cache } from "src/utils/payload"; import { contextCache } from "src/cache/contextCache";
import type { WordingKey } from "src/i18n/wordings-keys"; import type { WordingKey } from "src/i18n/wordings-keys";
const events = await payload.getChronologyEvents(); const events = await payload.getChronologyEvents();
@ -16,7 +16,7 @@ const { getLocalizedUrl, t, formatTimelineDate } = await getI18n(Astro.locals.cu
{/* ------------------------------------------- HTML ------------------------------------------- */} {/* ------------------------------------------- HTML ------------------------------------------- */}
<AppLayout backgroundImage={cache.config.timeline.backgroundImage} class="app"> <AppLayout backgroundImage={contextCache.config.timeline.backgroundImage} class="app">
<AppLayoutTitle title={t("timeline.title")} /> <AppLayoutTitle title={t("timeline.title")} />
<p class="prose" set:html={t("timeline.description")} /> <p class="prose" set:html={t("timeline.description")} />
@ -54,7 +54,7 @@ const { getLocalizedUrl, t, formatTimelineDate } = await getI18n(Astro.locals.cu
<h3>{t("timeline.jumpTo")}</h3> <h3>{t("timeline.jumpTo")}</h3>
{ {
cache.config.timeline.eras.map(({ name, startingYear, endingYear }) => ( contextCache.config.timeline.eras.map(({ name, startingYear, endingYear }) => (
<p <p
set:html={t(name as WordingKey, { set:html={t(name as WordingKey, {
start: `<a href="#${startingYear}">${formatTimelineDate({ year: startingYear })}</a>`, start: `<a href="#${startingYear}">${formatTimelineDate({ year: startingYear })}</a>`,

View File

@ -1,7 +1,7 @@
import { cache } from "src/utils/payload"; import { contextCache } from "src/cache/contextCache";
const getUnlocalizedPathname = (pathname: string): string => { const getUnlocalizedPathname = (pathname: string): string => {
for (const locale of cache.locales) { for (const locale of contextCache.locales) {
if (pathname.startsWith(`/${locale.id}`)) { if (pathname.startsWith(`/${locale.id}`)) {
return pathname.substring(`/${locale.id}`.length) || "/"; return pathname.substring(`/${locale.id}`.length) || "/";
} }
@ -30,7 +30,6 @@ export const trackRequest = (request: Request, { clientAddress, locals }: TrackR
attributes: { attributes: {
locale: locals.currentLocale, locale: locals.currentLocale,
currency: locals.currentCurrency, currency: locals.currentCurrency,
theme: locals.currentTheme,
}, },
}, },
request: { request: {

View File

@ -1,177 +1,177 @@
{ {
"disclaimer": "Usage subject to terms: https://openexchangerates.org/terms", "disclaimer": "Usage subject to terms: https://openexchangerates.org/terms",
"license": "https://openexchangerates.org/license", "license": "https://openexchangerates.org/license",
"timestamp": 1719374401, "timestamp": 1719518400,
"base": "USD", "base": "USD",
"rates": { "rates": {
"AED": 3.673, "AED": 3.673,
"AFN": 70.737389, "AFN": 70,
"ALL": 93.591883, "ALL": 93.656243,
"AMD": 388.464511, "AMD": 388.12,
"ANG": 1.804398, "ANG": 1.803057,
"AOA": 855.209667, "AOA": 853.629,
"ARS": 909.2076, "ARS": 911.0001,
"AUD": 1.498775, "AUD": 1.504506,
"AWG": 1.8025, "AWG": 1.8025,
"AZN": 1.7, "AZN": 1.7,
"BAM": 1.825905, "BAM": 1.827765,
"BBD": 2, "BBD": 2,
"BDT": 117.631657, "BDT": 117.53659,
"BGN": 1.8261, "BGN": 1.826865,
"BHD": 0.376925, "BHD": 0.376928,
"BIF": 2879.142593, "BIF": 2882.5,
"BMD": 1, "BMD": 1,
"BND": 1.354432, "BND": 1.357361,
"BOB": 6.917863, "BOB": 6.912666,
"BRL": 5.452199, "BRL": 5.5062,
"BSD": 1, "BSD": 1,
"BTC": 0.000016147327, "BTC": 0.000016286712,
"BTN": 83.525391, "BTN": 83.510338,
"BWP": 13.566127, "BWP": 13.649322,
"BYN": 3.276229, "BYN": 3.274029,
"BZD": 2.017943, "BZD": 2.016506,
"CAD": 1.365775, "CAD": 1.36937,
"CDF": 2845.344278, "CDF": 2860,
"CHF": 0.895078, "CHF": 0.898769,
"CLF": 0.034108, "CLF": 0.034588,
"CLP": 943.396226, "CLP": 954.24,
"CNH": 7.292088, "CNH": 7.303225,
"CNY": 7.2661, "CNY": 7.2683,
"COP": 4092.688822, "COP": 4147.064273,
"CRC": 524.018922, "CRC": 523.036765,
"CUC": 1, "CUC": 1,
"CUP": 25.75, "CUP": 25.75,
"CVE": 102.941746, "CVE": 103.063904,
"CZK": 23.187699, "CZK": 23.432,
"DJF": 178.249371, "DJF": 177.5,
"DKK": 6.9642, "DKK": 6.969109,
"DOP": 59.114309, "DOP": 59.2,
"DZD": 134.5516, "DZD": 134.481574,
"EGP": 48.377851, "EGP": 48.0279,
"ERN": 15, "ERN": 15,
"ETB": 57.353303, "ETB": 57.750301,
"EUR": 0.933624, "EUR": 0.934324,
"FJD": 2.2362, "FJD": 2.24125,
"FKP": 0.788219, "FKP": 0.791234,
"GBP": 0.788219, "GBP": 0.791234,
"GEL": 2.81, "GEL": 2.8,
"GGP": 0.788219, "GGP": 0.791234,
"GHS": 15.216498, "GHS": 15.25,
"GIP": 0.788219, "GIP": 0.791234,
"GMD": 67.75, "GMD": 67.775,
"GNF": 8617.038799, "GNF": 8595,
"GTQ": 7.774894, "GTQ": 7.773841,
"GYD": 209.365764, "GYD": 209.310316,
"HKD": 7.809496, "HKD": 7.808725,
"HNL": 24.774423, "HNL": 24.762821,
"HRK": 7.034221, "HRK": 7.039709,
"HTG": 132.791853, "HTG": 132.614267,
"HUF": 369.47, "HUF": 370.35232,
"IDR": 16421.779805, "IDR": 16384.008966,
"ILS": 3.74785, "ILS": 3.75783,
"IMP": 0.788219, "IMP": 0.791234,
"INR": 83.447347, "INR": 83.466142,
"IQD": 1311.401658, "IQD": 1310.629281,
"IRR": 42087.5, "IRR": 42100,
"ISK": 139.21, "ISK": 139.14,
"JEP": 0.788219, "JEP": 0.791234,
"JMD": 156.444628, "JMD": 156.065666,
"JOD": 0.7087, "JOD": 0.7087,
"JPY": 159.8375, "JPY": 160.819,
"KES": 129.646953, "KES": 129,
"KGS": 86.5868, "KGS": 86.45,
"KHR": 4119.876268, "KHR": 4116,
"KMF": 460.124977, "KMF": 460.04988,
"KPW": 900, "KPW": 900,
"KRW": 1390.097786, "KRW": 1387.206613,
"KWD": 0.306622, "KWD": 0.306775,
"KYD": 0.834247, "KYD": 0.833746,
"KZT": 467.84526, "KZT": 466.751615,
"LAK": 22038.352227, "LAK": 22077.44273,
"LBP": 89647.700512, "LBP": 89576.348385,
"LKR": 305.644882, "LKR": 306.055904,
"LRD": 194.349995, "LRD": 194.492735,
"LSL": 18.152071, "LSL": 18.359053,
"LYD": 4.855, "LYD": 4.87349,
"MAD": 9.950426, "MAD": 9.939151,
"MDL": 17.920244, "MDL": 17.849263,
"MGA": 4473.169164, "MGA": 4476.966706,
"MKD": 57.439335, "MKD": 57.476359,
"MMK": 2481.91, "MMK": 2481.91,
"MNT": 3450, "MNT": 3450,
"MOP": 8.051048, "MOP": 8.047221,
"MRU": 39.352282, "MRU": 39.452362,
"MUR": 46.929999, "MUR": 46.899999,
"MVR": 15.405, "MVR": 15.405,
"MWK": 1735.964257, "MWK": 1734.657401,
"MXN": 18.104717, "MXN": 18.41087,
"MYR": 4.71, "MYR": 4.7195,
"MZN": 63.850001, "MZN": 63.850001,
"NAD": 18.152071, "NAD": 18.359053,
"NGN": 1518.12, "NGN": 1515.9,
"NIO": 36.848246, "NIO": 36.825702,
"NOK": 10.607752, "NOK": 10.666237,
"NPR": 133.640175, "NPR": 133.611854,
"NZD": 1.635653, "NZD": 1.643791,
"OMR": 0.384952, "OMR": 0.384955,
"PAB": 1, "PAB": 1,
"PEN": 3.815747, "PEN": 3.823237,
"PGK": 3.906082, "PGK": 3.904308,
"PHP": 58.837749, "PHP": 58.639498,
"PKR": 278.808073, "PKR": 278.503056,
"PLN": 4.011559, "PLN": 4.029254,
"PYG": 7549.795422, "PYG": 7540.098869,
"QAR": 3.651064, "QAR": 3.649042,
"RON": 4.644, "RON": 4.6507,
"RSD": 109.292759, "RSD": 109.379523,
"RUB": 87.502462, "RUB": 84.998456,
"RWF": 1326.548635, "RWF": 1306.142519,
"SAR": 3.751926, "SAR": 3.751634,
"SBD": 8.43942, "SBD": 8.43942,
"SCR": 13.861839, "SCR": 13.796937,
"SDG": 601, "SDG": 601,
"SEK": 10.509533, "SEK": 10.62955,
"SGD": 1.354893, "SGD": 1.358205,
"SHP": 0.788219, "SHP": 0.791234,
"SLL": 20969.5, "SLL": 20969.5,
"SOS": 572.148966, "SOS": 571.751991,
"SRD": 31.0905, "SRD": 30.623,
"SSP": 130.26, "SSP": 130.26,
"STD": 22281.8, "STD": 22281.8,
"STN": 22.872934, "STN": 22.899229,
"SVC": 8.759785, "SVC": 8.754335,
"SYP": 2512.53, "SYP": 2512.53,
"SZL": 18.159157, "SZL": 18.178413,
"THB": 36.7755, "THB": 36.7685,
"TJS": 10.696778, "TJS": 10.65474,
"TMT": 3.51, "TMT": 3.51,
"TND": 3.133858, "TND": 3.136769,
"TOP": 2.359259, "TOP": 2.36092,
"TRY": 33.0006, "TRY": 32.836478,
"TTD": 6.801133, "TTD": 6.7978,
"TWD": 32.533, "TWD": 32.560001,
"TZS": 2638.159, "TZS": 2626.295328,
"UAH": 40.666635, "UAH": 40.51595,
"UGX": 3709.12772, "UGX": 3711.539326,
"USD": 1, "USD": 1,
"UYU": 39.322323, "UYU": 39.578963,
"UZS": 12613.6, "UZS": 12585.732694,
"VES": 36.320372, "VES": 36.35908,
"VND": 25461.48991, "VND": 25455.008685,
"VUV": 118.722, "VUV": 118.722,
"WST": 2.8, "WST": 2.8,
"XAF": 612.41733, "XAF": 612.876514,
"XAG": 0.03457994, "XAG": 0.0345453,
"XAU": 0.00043156, "XAU": 0.00042988,
"XCD": 2.70255, "XCD": 2.70255,
"XDR": 0.761402, "XDR": 0.759718,
"XOF": 612.41733, "XOF": 612.876514,
"XPD": 0.00107841, "XPD": 0.00108638,
"XPF": 111.411003, "XPF": 111.494537,
"XPT": 0.00101239, "XPT": 0.00101314,
"YER": 250.399984, "YER": 250.399984,
"ZAR": 18.227543, "ZAR": 18.4643,
"ZMW": 25.80335, "ZMW": 25.735569,
"ZWL": 322 "ZWL": 322
} }
} }

View File

@ -6,10 +6,10 @@ import {
type RichTextContent, type RichTextContent,
type RichTextNode, type RichTextNode,
} from "src/shared/payload/payload-sdk"; } from "src/shared/payload/payload-sdk";
import { cache } from "src/utils/payload"; import { contextCache } from "src/cache/contextCache";
export const formatLocale = (code: string): string => export const formatLocale = (code: string): string =>
cache.locales.find(({ id }) => id === code)?.name ?? code; contextCache.locales.find(({ id }) => id === code)?.name ?? code;
export const formatInlineTitle = ({ export const formatInlineTitle = ({
pretitle, pretitle,

6
src/utils/logger.ts Normal file
View File

@ -0,0 +1,6 @@
export const getLogger = (prefix: string): Pick<Console, "log" | "error" | "warn" | "debug"> => ({
debug: (...message) => console.debug(prefix, ...message),
log: (...message) => console.log(prefix, ...message),
warn: (...message) => console.warn(prefix, ...message),
error: (...message) => console.error(prefix, ...message),
});

View File

@ -1,18 +1,9 @@
import { import { dataCache } from "src/cache/dataCache";
type Language,
type EndpointWording,
type EndpointWebsiteConfig,
} from "src/shared/payload/payload-sdk";
import { getPayloadSDK } from "src/shared/payload/payload-sdk"; import { getPayloadSDK } from "src/shared/payload/payload-sdk";
let token: string | undefined = undefined; let token: string | undefined = undefined;
let expiration: number | undefined = undefined; let expiration: number | undefined = undefined;
const responseCache = new Map<string, any>();
const idsCacheMap = new Map<string, Set<string>>();
const isPrecachingEnabled = import.meta.env.ENABLE_PRECACHING === "true";
export const payload = getPayloadSDK({ export const payload = getPayloadSDK({
apiURL: import.meta.env.PAYLOAD_API_URL, apiURL: import.meta.env.PAYLOAD_API_URL,
email: import.meta.env.PAYLOAD_USER, email: import.meta.env.PAYLOAD_USER,
@ -33,108 +24,5 @@ export const payload = getPayloadSDK({
console.log("[PayloadSDK] New token set. TTL is", diffInMinutes, "minutes."); console.log("[PayloadSDK] New token set. TTL is", diffInMinutes, "minutes.");
}, },
}, },
responseCache: { responseCache: dataCache,
get: (url) => {
const cachedResponse = responseCache.get(url);
if (cachedResponse) {
console.log("[ResponseCaching] Retrieved cache response for", url);
return structuredClone(cachedResponse);
}
},
set: (url, response) => {
const stringData = JSON.stringify(response);
const regex = /[a-f0-9]{24}/g;
const ids = [...stringData.matchAll(regex)].map((match) => match[0]);
const uniqueIds = [...new Set(ids)];
uniqueIds.forEach((id) => {
const current = idsCacheMap.get(id);
if (current) {
current.add(url);
} else {
idsCacheMap.set(id, new Set([url]));
}
});
console.log("[ResponseCaching] Caching response for", url);
responseCache.set(url, response);
},
},
}); });
export const invalidateDataCache = async (ids: string[], urls: string[]) => {
const urlsToInvalidate = new Set<string>(urls);
ids.forEach((id) => {
const urlsForThisId = idsCacheMap.get(id);
if (!urlsForThisId) return;
idsCacheMap.delete(id);
[...urlsForThisId].forEach((url) => urlsToInvalidate.add(url));
});
for (const url of urlsToInvalidate) {
responseCache.delete(url);
console.log("[ResponseCaching][Invalidation] Deleted cache for", url);
try {
await payload.request(url);
} catch (e) {
console.log("[ResponseCaching][Revalidation] Failure for", url);
}
}
console.log("[ResponseCaching] There are currently", responseCache.size, "responses in cache.");
};
type Cache = {
locales: Language[];
currencies: string[];
wordings: EndpointWording[];
config: EndpointWebsiteConfig;
};
const fetchNewData = async (): Promise<Cache> => ({
locales: await payload.getLanguages(),
currencies: (await payload.getCurrencies()).map(({ id }) => id),
wordings: await payload.getWordings(),
config: await payload.getConfig(),
});
export let cache = await fetchNewData();
export const refreshWordings = async () => {
cache.wordings = await payload.getWordings();
};
export const refreshCurrencies = async () => {
cache.currencies = (await payload.getCurrencies()).map(({ id }) => id);
};
export const refreshLocales = async () => {
cache.locales = await payload.getLanguages();
};
export const refreshWebsiteConfig = async () => {
cache.config = await payload.getConfig();
};
let payloadInitialized = false;
export const initPayload = async () => {
if (!payloadInitialized) {
if (!isPrecachingEnabled) {
payloadInitialized = true;
return;
}
const { urls } = await payload.getAllSdkUrls();
for (const url of urls) {
try {
await payload.request(url);
} catch {
console.warn("[ResponseCaching] Precaching failed for url", url);
}
}
console.log("[ResponseCaching] Precaching completed!", responseCache.size, "responses cached");
payloadInitialized = true;
}
};