refactor: rework API loader and caching
All checks were successful
deploy / deploy (push) Successful in 2m24s
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:
parent
24d558e0f5
commit
d54aabd621
@ -11,15 +11,14 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
- run: corepack enable
|
||||
- run: yarn install --frozen-lockfile
|
||||
node-version: 22.x
|
||||
- run: npm ci
|
||||
- uses: purcell/setup-emacs@master
|
||||
with:
|
||||
version: 29.1
|
||||
- name: "Export org to md"
|
||||
run: emacs -Q --script export.el
|
||||
- run: yarn build
|
||||
- run: npm run build
|
||||
- name: "Deploy on the Web"
|
||||
uses: appleboy/scp-action@v0.1.7
|
||||
with:
|
||||
|
@ -4,9 +4,8 @@ import ListRepositories from './components/GitHub/ListRepositories.vue';
|
||||
import FetchRepositories from './components/GitHub/FetchRepositories.vue';
|
||||
import GithubRepository from './components/GitHub/GithubRepository.vue';
|
||||
import ApiLoader from './components/ApiLoader.vue';
|
||||
import Loader from './components/Loader.vue';
|
||||
import Cache from './components/Cache.vue';
|
||||
import Error from './components/Error.vue';
|
||||
import LoaderAnimation from './components/LoaderAnimation.vue';
|
||||
import FetchError from './components/FetchError.vue';
|
||||
import Icon from './components/Icon.vue';
|
||||
|
||||
export default defineClientConfig({
|
||||
@ -16,9 +15,8 @@ export default defineClientConfig({
|
||||
app.component('FetchRepositories', FetchRepositories);
|
||||
app.component('GithubRepository', GithubRepository);
|
||||
app.component('ApiLoader', ApiLoader);
|
||||
app.component('Loader', Loader);
|
||||
app.component('Cache', Cache);
|
||||
app.component('Error', Error);
|
||||
app.component('LoaderAnimation', LoaderAnimation);
|
||||
app.component('FetchError', FetchError);
|
||||
app.component('Icon', Icon);
|
||||
},
|
||||
setup() {},
|
||||
|
@ -1,31 +1,22 @@
|
||||
<template>
|
||||
<Cache
|
||||
:name="props.cacheName"
|
||||
:callback="fetchData"
|
||||
:already-known-data="alreadyKnownData"
|
||||
@cached="processCachedData"
|
||||
/>
|
||||
<slot v-if="loading" name="loader">
|
||||
<Loader />
|
||||
<LoaderAnimation />
|
||||
</slot>
|
||||
<slot v-else-if="error" name="error">
|
||||
<Error :url="props.url" />
|
||||
<FetchError :url="props.url" />
|
||||
</slot>
|
||||
<slot v-else> </slot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Cache from './Cache.vue';
|
||||
import Loader from './Loader.vue';
|
||||
import Error from './Error.vue';
|
||||
import LoaderAnimation from './LoaderAnimation.vue';
|
||||
import FetchError from './FetchError.vue';
|
||||
|
||||
import { Ref, ref } from 'vue';
|
||||
import { Observable, catchError, switchMap, throwError } from 'rxjs';
|
||||
import { fromFetch } from 'rxjs/fetch';
|
||||
import { useFetchAndCache } from '../composables/fetchAndCache';
|
||||
|
||||
const props = defineProps({
|
||||
url: {
|
||||
default: false,
|
||||
default: '',
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
@ -35,31 +26,11 @@ const props = defineProps({
|
||||
},
|
||||
alreadyKnownData: Object,
|
||||
});
|
||||
const emits = defineEmits(['dataLoaded', 'dataError', 'loading']);
|
||||
|
||||
const error: Ref<Error> = ref(null);
|
||||
const loading: Ref<boolean> = ref(true);
|
||||
const emits = defineEmits(['loaded', 'error', 'loading']);
|
||||
|
||||
const fetchData = (): Observable<any> => {
|
||||
return fromFetch(props.url).pipe(
|
||||
switchMap((response: Response) => response.json()),
|
||||
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;
|
||||
},
|
||||
const { loading, error } = useFetchAndCache(props.url, {
|
||||
emits: emits,
|
||||
cacheName: props.cacheName,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
@ -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>
|
@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<ApiLoader :url="fetchUrl" @dataLoaded="filterRepos" cache-name="repos" />
|
||||
<ApiLoader :url="fetchUrl" @loaded="filterRepos" cache-name="repos" />
|
||||
<slot />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PropType } from 'vue';
|
||||
import { GithubRepo } from '../../composables/github';
|
||||
import { PropType, ref } from 'vue';
|
||||
import { GithubRepo } from '../../types/github';
|
||||
const props = defineProps({
|
||||
sortBy: {
|
||||
default: 'none',
|
||||
@ -23,15 +23,12 @@ const props = defineProps({
|
||||
type: Number,
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['dataLoaded']);
|
||||
|
||||
const emits = defineEmits(['loaded']);
|
||||
const fetchUrl = `https://api.github.com/users/${props.user}/repos?per_page=100`;
|
||||
const repos = ref<GithubRepo[]>([]);
|
||||
|
||||
const filterRepos = (response: GithubRepo[]) => {
|
||||
emits(
|
||||
'dataLoaded',
|
||||
response
|
||||
repos.value = response
|
||||
.sort((a, b) => {
|
||||
if (props.sortBy === 'stars') {
|
||||
return b.stargazers_count - a.stargazers_count;
|
||||
@ -43,7 +40,7 @@ const filterRepos = (response: GithubRepo[]) => {
|
||||
}
|
||||
return b.forks_count - a.forks_count;
|
||||
})
|
||||
.slice(0, +props.limit)
|
||||
);
|
||||
.slice(0, +props.limit);
|
||||
emits('loaded', repos.value);
|
||||
};
|
||||
</script>
|
||||
|
@ -6,23 +6,23 @@
|
||||
:cache-name="repoName()"
|
||||
:url="fetchUrl"
|
||||
: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>
|
||||
<p>
|
||||
{{ repository.description }}
|
||||
{{ repository?.description }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-row flex-start gap-1rem stats">
|
||||
<div class="stars">
|
||||
<Icon name="star" /> {{ repository.stargazers_count }}
|
||||
<Icon name="star" /> {{ repository?.stargazers_count }}
|
||||
</div>
|
||||
<div class="forks">
|
||||
<Icon name="fork" /> {{ repository.forks_count }}
|
||||
<Icon name="fork" /> {{ repository?.forks_count }}
|
||||
</div>
|
||||
<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>
|
||||
</ApiLoader>
|
||||
@ -32,7 +32,7 @@
|
||||
<script setup lang="ts">
|
||||
import ApiLoader from '../ApiLoader.vue';
|
||||
|
||||
import { GithubRepo } from '../../composables/github';
|
||||
import { GithubRepo } from '../../types/github';
|
||||
import { PropType, Ref, ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
@ -45,7 +45,7 @@ const repoName = (): string => {
|
||||
};
|
||||
|
||||
const fetchUrl = `https://api.github.com/repos/${repoName()}`;
|
||||
const repository: Ref<GithubRepo> = ref(null);
|
||||
const repository: Ref<GithubRepo | null> = ref(null);
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
@ -78,5 +78,12 @@ const repository: Ref<GithubRepo> = ref(null);
|
||||
gap: 0.3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.link {
|
||||
a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -5,7 +5,7 @@
|
||||
:sort-by="props.sortBy"
|
||||
:user="props.user"
|
||||
:limit="props.limit"
|
||||
@data-loaded="(response: GithubRepo[]) => (repos = response)"
|
||||
@loaded="(response: GithubRepo[]) => (repos = response)"
|
||||
>
|
||||
<GithubRepository
|
||||
:data="repo"
|
||||
@ -22,7 +22,7 @@ import FetchRepositories from './FetchRepositories.vue';
|
||||
import GithubRepository from './GithubRepository.vue';
|
||||
|
||||
import { PropType, Ref, ref } from 'vue';
|
||||
import { GithubRepo } from '../../composables/github';
|
||||
import { GithubRepo } from '../../types/github';
|
||||
|
||||
const props = defineProps({
|
||||
sortBy: {
|
||||
|
62
content/.vuepress/composables/cache.ts
Normal file
62
content/.vuepress/composables/cache.ts
Normal 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 };
|
||||
};
|
72
content/.vuepress/composables/fetchAndCache.ts
Normal file
72
content/.vuepress/composables/fetchAndCache.ts
Normal 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 };
|
||||
};
|
@ -2,13 +2,13 @@ import { defaultTheme } from '@vuepress/theme-default';
|
||||
import { viteBundler } from '@vuepress/bundler-vite';
|
||||
import { defineUserConfig } from 'vuepress';
|
||||
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 { locales, searchLocales } from './locales';
|
||||
import { themeLocales } from './themeLocales';
|
||||
|
||||
let isProd = process.env.NODE_ENV === 'production';
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
export default defineUserConfig({
|
||||
lang: 'fr-FR',
|
||||
@ -29,8 +29,7 @@ export default defineUserConfig({
|
||||
isProd
|
||||
? umamiAnalyticsPlugin({
|
||||
id: '67166941-8c83-4a19-bc8c-139e44b7f7aa',
|
||||
src: 'https://umami.phundrak.com/script.js',
|
||||
doNotTrack: true,
|
||||
link: 'https://umami.phundrak.com/script.js',
|
||||
})
|
||||
: [],
|
||||
],
|
||||
@ -39,5 +38,9 @@ export default defineUserConfig({
|
||||
contributors: false,
|
||||
locales: themeLocales,
|
||||
repo: 'https://labs.phundrak.com/phundrak/phundrak.com',
|
||||
themePlugins: {
|
||||
copyCode: false,
|
||||
prismjs: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
@ -123,9 +123,9 @@ const simplifiedHead: SimplifiedHeader[] = [
|
||||
},
|
||||
];
|
||||
|
||||
let headBuilder = [];
|
||||
const headBuilder = [];
|
||||
simplifiedHead.forEach((tag) => {
|
||||
tag.content.forEach((element: any) => {
|
||||
tag.content.forEach((element) => {
|
||||
headBuilder.push([tag.tag, element]);
|
||||
});
|
||||
});
|
||||
|
5295
package-lock.json
generated
Normal file
5295
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@ -8,27 +8,26 @@
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@vuepress/bundler-vite": "^2.0.0-rc.2",
|
||||
"@vuepress/theme-default": "^2.0.0-rc.2",
|
||||
"@vuepress/bundler-vite": "2.0.0-rc.13",
|
||||
"@vuepress/plugin-umami-analytics": "^2.0.0-rc.36",
|
||||
"@vuepress/theme-default": "^2.0.0-rc.36",
|
||||
"cz-conventional-changelog": "^3.3.0",
|
||||
"git-cliff": "^1.1.2",
|
||||
"vuepress": "2.0.0-rc.2",
|
||||
"vuepress-plugin-search-pro": "^2.0.0-rc.15"
|
||||
"git-cliff": "^1.4.0",
|
||||
"vuepress": "2.0.0-rc.13",
|
||||
"vuepress-plugin-search-pro": "^2.0.0-rc.43"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vuepress dev content",
|
||||
"build": "vuepress build content"
|
||||
},
|
||||
"dependencies": {
|
||||
"less": "^4.1.3",
|
||||
"nord": "^0.2.1",
|
||||
"rxjs": "^7.8.1",
|
||||
"vuepress-plugin-umami-analytics": "^1.8.0"
|
||||
"less": "^4.2.0",
|
||||
"nord": "^0.2.1"
|
||||
},
|
||||
"config": {
|
||||
"commitizen": {
|
||||
"path": "./node_modules/cz-conventional-changelog"
|
||||
}
|
||||
},
|
||||
"packageManager": "yarn@4.1.0"
|
||||
"packageManager": "yarn@4.3.0"
|
||||
}
|
||||
|
6
shell.nix
Normal file
6
shell.nix
Normal file
@ -0,0 +1,6 @@
|
||||
{ pkgs ? import <nixpkgs> {} }:
|
||||
pkgs.mkShell {
|
||||
nativeBuildInputs = with pkgs; [
|
||||
nodejs_22
|
||||
];
|
||||
}
|
Loading…
Reference in New Issue
Block a user