refactor: rework API loader and caching
All checks were successful
deploy / deploy (push) Successful in 2m24s

This commit removes dependency on rxjs.

It also implements better composables to handle data fetching from
remote APIs and caching these values more transparently.

This commit also switches from yarn to npm

It also switches to the official Umami plugin
This commit is contained in:
Lucien Cartier-Tilet 2024-06-20 09:27:59 +02:00
parent 24d558e0f5
commit d54aabd621
Signed by: phundrak
GPG Key ID: BD7789E705CB8DCA
19 changed files with 5508 additions and 4883 deletions

1
.envrc Normal file
View File

@ -0,0 +1 @@
use nix

View File

@ -11,15 +11,14 @@ jobs:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 18.x node-version: 22.x
- run: corepack enable - run: npm ci
- run: yarn install --frozen-lockfile
- uses: purcell/setup-emacs@master - uses: purcell/setup-emacs@master
with: with:
version: 29.1 version: 29.1
- name: "Export org to md" - name: "Export org to md"
run: emacs -Q --script export.el run: emacs -Q --script export.el
- run: yarn build - run: npm run build
- name: "Deploy on the Web" - name: "Deploy on the Web"
uses: appleboy/scp-action@v0.1.7 uses: appleboy/scp-action@v0.1.7
with: with:

View File

@ -4,9 +4,8 @@ import ListRepositories from './components/GitHub/ListRepositories.vue';
import FetchRepositories from './components/GitHub/FetchRepositories.vue'; import FetchRepositories from './components/GitHub/FetchRepositories.vue';
import GithubRepository from './components/GitHub/GithubRepository.vue'; import GithubRepository from './components/GitHub/GithubRepository.vue';
import ApiLoader from './components/ApiLoader.vue'; import ApiLoader from './components/ApiLoader.vue';
import Loader from './components/Loader.vue'; import LoaderAnimation from './components/LoaderAnimation.vue';
import Cache from './components/Cache.vue'; import FetchError from './components/FetchError.vue';
import Error from './components/Error.vue';
import Icon from './components/Icon.vue'; import Icon from './components/Icon.vue';
export default defineClientConfig({ export default defineClientConfig({
@ -16,9 +15,8 @@ export default defineClientConfig({
app.component('FetchRepositories', FetchRepositories); app.component('FetchRepositories', FetchRepositories);
app.component('GithubRepository', GithubRepository); app.component('GithubRepository', GithubRepository);
app.component('ApiLoader', ApiLoader); app.component('ApiLoader', ApiLoader);
app.component('Loader', Loader); app.component('LoaderAnimation', LoaderAnimation);
app.component('Cache', Cache); app.component('FetchError', FetchError);
app.component('Error', Error);
app.component('Icon', Icon); app.component('Icon', Icon);
}, },
setup() {}, setup() {},

View File

@ -1,31 +1,22 @@
<template> <template>
<Cache
:name="props.cacheName"
:callback="fetchData"
:already-known-data="alreadyKnownData"
@cached="processCachedData"
/>
<slot v-if="loading" name="loader"> <slot v-if="loading" name="loader">
<Loader /> <LoaderAnimation />
</slot> </slot>
<slot v-else-if="error" name="error"> <slot v-else-if="error" name="error">
<Error :url="props.url" /> <FetchError :url="props.url" />
</slot> </slot>
<slot v-else> </slot> <slot v-else> </slot>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import Cache from './Cache.vue'; import LoaderAnimation from './LoaderAnimation.vue';
import Loader from './Loader.vue'; import FetchError from './FetchError.vue';
import Error from './Error.vue';
import { Ref, ref } from 'vue'; import { useFetchAndCache } from '../composables/fetchAndCache';
import { Observable, catchError, switchMap, throwError } from 'rxjs';
import { fromFetch } from 'rxjs/fetch';
const props = defineProps({ const props = defineProps({
url: { url: {
default: false, default: '',
required: true, required: true,
type: String, type: String,
}, },
@ -35,31 +26,11 @@ const props = defineProps({
}, },
alreadyKnownData: Object, alreadyKnownData: Object,
}); });
const emits = defineEmits(['dataLoaded', 'dataError', 'loading']);
const error: Ref<Error> = ref(null); const emits = defineEmits(['loaded', 'error', 'loading']);
const loading: Ref<boolean> = ref(true);
const fetchData = (): Observable<any> => { const { loading, error } = useFetchAndCache(props.url, {
return fromFetch(props.url).pipe( emits: emits,
switchMap((response: Response) => response.json()), cacheName: props.cacheName,
catchError((errorResponse: Error) => { });
error.value = errorResponse;
return Error;
})
);
};
const processCachedData = (data: Observable<any>) => {
data.subscribe({
next: (response: any) => {
loading.value = false;
emits('dataLoaded', response);
},
error: (responseError: Error) => {
loading.value = false;
error.value = responseError;
},
});
};
</script> </script>

View File

@ -1,67 +0,0 @@
<template>
<slot />
</template>
<script setup lang="ts">
import { Observable, of, tap } from 'rxjs';
const props = defineProps({
name: {
required: true,
type: String,
},
callback: {
required: true,
type: Function,
},
lifetime: {
default: 1000 * 60 * 60, // one hour
required: false,
type: Number,
},
alreadyKnownData: {
default: null,
type: Object,
},
});
const emits = defineEmits(['cached']);
const isDataOutdated = (name: string): boolean => {
const lastUpdated: number = +localStorage.getItem(name + '-timestamp');
const elapsedTime: number = Date.now() - lastUpdated;
return elapsedTime > props.lifetime;
};
const storeInCache = (
callback: Function,
data: any,
name: string,
): Observable<any> => {
let response: Observable<any> = data ? of(data) : callback();
return response.pipe(
tap((response) => {
localStorage.setItem(name, JSON.stringify(response));
localStorage.setItem(name + '-timestamp', `${Date.now()}`);
}),
);
};
if (isDataOutdated(props.name)) {
emits(
'cached',
storeInCache(props.callback, props.alreadyKnownData, props.name),
);
} else {
let data = localStorage.getItem(props.name);
try {
emits('cached', of(JSON.parse(data)));
} catch (err) {
console.error(`Could not parse data found in cache: ${err}`);
emits(
'cached',
storeInCache(props.callback, props.alreadyKnownData, props.name),
);
}
}
</script>

View File

@ -1,11 +1,11 @@
<template> <template>
<ApiLoader :url="fetchUrl" @dataLoaded="filterRepos" cache-name="repos" /> <ApiLoader :url="fetchUrl" @loaded="filterRepos" cache-name="repos" />
<slot /> <slot />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { PropType } from 'vue'; import { PropType, ref } from 'vue';
import { GithubRepo } from '../../composables/github'; import { GithubRepo } from '../../types/github';
const props = defineProps({ const props = defineProps({
sortBy: { sortBy: {
default: 'none', default: 'none',
@ -23,15 +23,12 @@ const props = defineProps({
type: Number, type: Number,
}, },
}); });
const emits = defineEmits(['loaded']);
const emits = defineEmits(['dataLoaded']);
const fetchUrl = `https://api.github.com/users/${props.user}/repos?per_page=100`; const fetchUrl = `https://api.github.com/users/${props.user}/repos?per_page=100`;
const repos = ref<GithubRepo[]>([]);
const filterRepos = (response: GithubRepo[]) => { const filterRepos = (response: GithubRepo[]) => {
emits( repos.value = response
'dataLoaded',
response
.sort((a, b) => { .sort((a, b) => {
if (props.sortBy === 'stars') { if (props.sortBy === 'stars') {
return b.stargazers_count - a.stargazers_count; return b.stargazers_count - a.stargazers_count;
@ -43,7 +40,7 @@ const filterRepos = (response: GithubRepo[]) => {
} }
return b.forks_count - a.forks_count; return b.forks_count - a.forks_count;
}) })
.slice(0, +props.limit) .slice(0, +props.limit);
); emits('loaded', repos.value);
}; };
</script> </script>

View File

@ -6,23 +6,23 @@
:cache-name="repoName()" :cache-name="repoName()"
:url="fetchUrl" :url="fetchUrl"
:already-known-data="props.data" :already-known-data="props.data"
@data-loaded="(repo: GithubRepo) => (repository = repo)" @loaded="(repo: GithubRepo) => (repository = repo)"
> >
<h3>{{ repository.name }}</h3> <h3>{{ repository?.name }}</h3>
<div> <div>
<p> <p>
{{ repository.description }} {{ repository?.description }}
</p> </p>
</div> </div>
<div class="flex-row flex-start gap-1rem stats"> <div class="flex-row flex-start gap-1rem stats">
<div class="stars"> <div class="stars">
<Icon name="star" /> {{ repository.stargazers_count }} <Icon name="star" /> {{ repository?.stargazers_count }}
</div> </div>
<div class="forks"> <div class="forks">
<Icon name="fork" /> {{ repository.forks_count }} <Icon name="fork" /> {{ repository?.forks_count }}
</div> </div>
<div class="link"> <div class="link">
<a :href="repository.html_url"><i class="icon phunic-link" /></a> <a :href="repository?.html_url"><i class="icon phunic-link" /></a>
</div> </div>
</div> </div>
</ApiLoader> </ApiLoader>
@ -32,7 +32,7 @@
<script setup lang="ts"> <script setup lang="ts">
import ApiLoader from '../ApiLoader.vue'; import ApiLoader from '../ApiLoader.vue';
import { GithubRepo } from '../../composables/github'; import { GithubRepo } from '../../types/github';
import { PropType, Ref, ref } from 'vue'; import { PropType, Ref, ref } from 'vue';
const props = defineProps({ const props = defineProps({
@ -45,7 +45,7 @@ const repoName = (): string => {
}; };
const fetchUrl = `https://api.github.com/repos/${repoName()}`; const fetchUrl = `https://api.github.com/repos/${repoName()}`;
const repository: Ref<GithubRepo> = ref(null); const repository: Ref<GithubRepo | null> = ref(null);
</script> </script>
<style lang="less"> <style lang="less">
@ -78,5 +78,12 @@ const repository: Ref<GithubRepo> = ref(null);
gap: 0.3rem; gap: 0.3rem;
} }
} }
.link {
a {
display: flex;
align-items: center;
}
}
} }
</style> </style>

View File

@ -5,7 +5,7 @@
:sort-by="props.sortBy" :sort-by="props.sortBy"
:user="props.user" :user="props.user"
:limit="props.limit" :limit="props.limit"
@data-loaded="(response: GithubRepo[]) => (repos = response)" @loaded="(response: GithubRepo[]) => (repos = response)"
> >
<GithubRepository <GithubRepository
:data="repo" :data="repo"
@ -22,7 +22,7 @@ import FetchRepositories from './FetchRepositories.vue';
import GithubRepository from './GithubRepository.vue'; import GithubRepository from './GithubRepository.vue';
import { PropType, Ref, ref } from 'vue'; import { PropType, Ref, ref } from 'vue';
import { GithubRepo } from '../../composables/github'; import { GithubRepo } from '../../types/github';
const props = defineProps({ const props = defineProps({
sortBy: { sortBy: {

View File

@ -0,0 +1,62 @@
import { Ref, computed, ref, watchEffect } from 'vue';
interface CacheOptions {
lifetime?: number;
timestampSuffix?: string;
forceUpdate?: boolean;
}
/**
* Cache data in local storage.
*
* The cache is updated if:
* - cache data does not exist
* - cached data is outdated and `data` is not null
* - or `options.forceUpdate` is true, regardless of the value of `data`
*
* Otherwise, data is retrieved from cache.
*
* @param {string} name Name of the cached value in local storage
* @param {Ref<T>} data Data to cache
* @param {CacheOptions} options Tweaks to the behaviour of the function
*/
export const useCache = <T>(
name: string,
data: Ref<T>,
options: CacheOptions,
) => {
const error = ref<string>(null);
const timestampName = name + (options?.timestampSuffix || '-timestamp');
const lifetime = options?.lifetime || 1000 * 60 * 60; // one hour in milliseconds
const lastUpdated: number = +localStorage.getItem(timestampName);
const cacheAge: number = Date.now() - lastUpdated;
const isDataOutdated = computed(() => {
return cacheAge > lifetime;
});
const shouldUpdate = computed(
() => options?.forceUpdate || (isDataOutdated.value && data.value != null),
);
const setData = () => {
console.log('Setting data in cache with name', name);
localStorage.setItem(name, JSON.stringify(data.value));
localStorage.setItem(timestampName, `${Date.now()}`);
};
const getData = () => {
console.log('Getting data from cache with name', name);
const cached = localStorage.getItem(name);
console.log('Value from storage:', cached);
try {
data.value = JSON.parse(cached);
} catch (err) {
console.error('Failed to parse cached data:', err);
data.value = null;
error.value = err;
}
};
getData();
watchEffect(() => (shouldUpdate.value ? setData() : getData()));
return { error, isDataOutdated };
};

View File

@ -0,0 +1,72 @@
import { ref, Ref } from 'vue';
import { useCache } from './cache';
type FetchAndCacheEmitter = (
event: 'loaded' | 'error' | 'loading',
...args: any[]
) => void;
interface UseFetchAndCacheOptions {
cacheLifetime?: number;
cacheName?: string;
emits?: FetchAndCacheEmitter;
}
const dummyEmits = (
_event: 'loaded' | 'error' | 'loading',
..._args: any[]
) => {};
export const useFetchAndCache = <T, E>(
url: string,
options?: UseFetchAndCacheOptions,
) => {
const data = ref<T | null>(null) as Ref<T>;
const error = ref<E | null>(null) as Ref<E>;
const loading = ref<boolean>(true);
const cacheLifetime: number = options?.cacheLifetime || 1000 * 60 * 60; // one hour
const cacheName: string = options?.cacheName || url;
const { isDataOutdated: isCacheOutdated, error: cacheError } = useCache(
cacheName,
data,
{
lifetime: cacheLifetime,
},
);
const emits: FetchAndCacheEmitter = options?.emits || dummyEmits;
const loaded = () => {
loading.value = false;
emits('loaded', data.value);
};
const fetchData = () => {
loading.value = true;
emits('loading');
console.log('Fetching from URL', url);
fetch(url)
.then((response) => {
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json() as Promise<T>;
})
.then((responseData) => {
data.value = responseData;
loaded();
})
.catch((e) => {
console.warn('Caught error!', e);
error.value = e;
emits('error', e);
})
.finally(() => (loading.value = false));
};
if (isCacheOutdated.value || cacheError.value != null) {
fetchData();
} else {
loaded();
}
return { data, loading, error };
};

View File

@ -2,13 +2,13 @@ import { defaultTheme } from '@vuepress/theme-default';
import { viteBundler } from '@vuepress/bundler-vite'; import { viteBundler } from '@vuepress/bundler-vite';
import { defineUserConfig } from 'vuepress'; import { defineUserConfig } from 'vuepress';
import { searchProPlugin } from 'vuepress-plugin-search-pro'; import { searchProPlugin } from 'vuepress-plugin-search-pro';
import { umamiAnalyticsPlugin } from 'vuepress-plugin-umami-analytics'; import { umamiAnalyticsPlugin } from '@vuepress/plugin-umami-analytics';
import { head } from './head'; import { head } from './head';
import { locales, searchLocales } from './locales'; import { locales, searchLocales } from './locales';
import { themeLocales } from './themeLocales'; import { themeLocales } from './themeLocales';
let isProd = process.env.NODE_ENV === 'production'; const isProd = process.env.NODE_ENV === 'production';
export default defineUserConfig({ export default defineUserConfig({
lang: 'fr-FR', lang: 'fr-FR',
@ -29,8 +29,7 @@ export default defineUserConfig({
isProd isProd
? umamiAnalyticsPlugin({ ? umamiAnalyticsPlugin({
id: '67166941-8c83-4a19-bc8c-139e44b7f7aa', id: '67166941-8c83-4a19-bc8c-139e44b7f7aa',
src: 'https://umami.phundrak.com/script.js', link: 'https://umami.phundrak.com/script.js',
doNotTrack: true,
}) })
: [], : [],
], ],
@ -39,5 +38,9 @@ export default defineUserConfig({
contributors: false, contributors: false,
locales: themeLocales, locales: themeLocales,
repo: 'https://labs.phundrak.com/phundrak/phundrak.com', repo: 'https://labs.phundrak.com/phundrak/phundrak.com',
themePlugins: {
copyCode: false,
prismjs: false,
},
}), }),
}); });

View File

@ -123,9 +123,9 @@ const simplifiedHead: SimplifiedHeader[] = [
}, },
]; ];
let headBuilder = []; const headBuilder = [];
simplifiedHead.forEach((tag) => { simplifiedHead.forEach((tag) => {
tag.content.forEach((element: any) => { tag.content.forEach((element) => {
headBuilder.push([tag.tag, element]); headBuilder.push([tag.tag, element]);
}); });
}); });

5295
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -8,27 +8,26 @@
"license": "AGPL-3.0", "license": "AGPL-3.0",
"private": true, "private": true,
"devDependencies": { "devDependencies": {
"@vuepress/bundler-vite": "^2.0.0-rc.2", "@vuepress/bundler-vite": "2.0.0-rc.13",
"@vuepress/theme-default": "^2.0.0-rc.2", "@vuepress/plugin-umami-analytics": "^2.0.0-rc.36",
"@vuepress/theme-default": "^2.0.0-rc.36",
"cz-conventional-changelog": "^3.3.0", "cz-conventional-changelog": "^3.3.0",
"git-cliff": "^1.1.2", "git-cliff": "^1.4.0",
"vuepress": "2.0.0-rc.2", "vuepress": "2.0.0-rc.13",
"vuepress-plugin-search-pro": "^2.0.0-rc.15" "vuepress-plugin-search-pro": "^2.0.0-rc.43"
}, },
"scripts": { "scripts": {
"dev": "vuepress dev content", "dev": "vuepress dev content",
"build": "vuepress build content" "build": "vuepress build content"
}, },
"dependencies": { "dependencies": {
"less": "^4.1.3", "less": "^4.2.0",
"nord": "^0.2.1", "nord": "^0.2.1"
"rxjs": "^7.8.1",
"vuepress-plugin-umami-analytics": "^1.8.0"
}, },
"config": { "config": {
"commitizen": { "commitizen": {
"path": "./node_modules/cz-conventional-changelog" "path": "./node_modules/cz-conventional-changelog"
} }
}, },
"packageManager": "yarn@4.1.0" "packageManager": "yarn@4.3.0"
} }

6
shell.nix Normal file
View File

@ -0,0 +1,6 @@
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
nativeBuildInputs = with pkgs; [
nodejs_22
];
}

4718
yarn.lock

File diff suppressed because it is too large Load Diff