refactor: move lots of logic to hooks, declutter (#22)

* refactor: move lots of logic to hooks, declutter

* refactor: useNavigation, deprecate useAppState entirely

* fix: staleTime is a number

* fix: standard html mode

* fix: useSettings with safe defaults

* fix: terminal slash in all accepted URLs, redirecting

* infra: redirects

* infra: redirects from config
This commit is contained in:
Marc 2022-03-13 10:19:00 -04:00 committed by GitHub
parent c67477ee1b
commit 0d06488d13
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 378 additions and 292 deletions

View file

@ -7,3 +7,8 @@
targetPort = 1234
port = 8080
framework = "parcel"
[[redirects]]
from = "/*"
to = "/index.html"
status = 200

View file

@ -41,7 +41,8 @@
"@material-ui/core": "^4.12.1",
"@material-ui/icons": "^4.11.2",
"htmlparser2": "^6.1.0",
"preact": "^10.5.14"
"preact": "^10.5.14",
"react-query": "^3.34.16"
},
"alias": {
"react": "preact/compat",

View file

@ -1,50 +1,18 @@
import { FunctionComponent } from 'preact'
import { useEffect } from 'preact/hooks'
import fetchFeeds from './utils/fetchFeeds'
import useNavigation, { routes } from './hooks/useNavigation'
import NavigationBar from './NavigationBar'
import FeedsPanel from './FeedsPanel'
import SettingsPanel from './SettingsPanel'
import { panelIdentifiers } from './constants'
import { restoreSettings } from './utils/persistence'
import useAppState from './utils/useAppState'
export default function App(): FunctionComponent<> {
const [state, actions] = useAppState()
const { setActivePanel, setFeeds, setFeedUrls } = actions
useEffect(() => {
if (state.loaded) return
setFeedUrls(restoreSettings()?.feedUrls ?? [])
}, [state.loaded, setFeedUrls])
useEffect(() => {
const fetch = async () => {
const feeds = await fetchFeeds(state.feedUrls)
setFeeds(feeds)
}
fetch()
}, [state.feedUrls, setFeeds])
if (!state.feeds) return 'loading'
export default function App(): FunctionComponent {
const { location } = useNavigation()
return (
<>
<NavigationBar
activePanel={state.activePanel}
setActivePanel={setActivePanel}
/>
{state.activePanel === panelIdentifiers.FEEDS ? (
<FeedsPanel feeds={state.feeds} />
) : null}
{state.activePanel === panelIdentifiers.SETTINGS ? (
<SettingsPanel
feedUrls={state.feedUrls}
setFeedUrls={setFeedUrls}
/>
) : null}
<NavigationBar />
{location === routes.FEEDS ? <FeedsPanel /> : null}
{location === routes.SETTINGS ? <SettingsPanel /> : null}
</>
)
}

View file

@ -4,12 +4,10 @@ import Typography from '@material-ui/core/Typography'
import Card from '@material-ui/core/Card'
import { makeStyles } from '@material-ui/core/styles'
import useSettings from './hooks/useSettings'
import useRSSFeeds from './hooks/useRSSFeeds'
import sortFeedItemsByDate from './utils/sortFeedItemsByDate'
import type { Feed } from './types'
interface Props {
feeds: Feed[]
}
interface CardProps {
title: string
url: string
@ -59,8 +57,10 @@ function NoItemsNotice() {
)
}
export default function FeedsPanel(props: Props): FunctionComponent<Props> {
const { feeds } = props
export default function FeedsPanel(): FunctionComponent {
const { getSettings } = useSettings()
const settings = getSettings()
const { feeds } = useRSSFeeds(settings.feedUrls)
const flattenedItems = sortFeedItemsByDate(feeds)

View file

@ -5,32 +5,23 @@ import Typography from '@material-ui/core/Typography'
import Button from '@material-ui/core/Button'
import { makeStyles } from '@material-ui/core/styles'
import { panelIdentifiers, readablePanelIdentifiers } from './constants'
interface NavigationBarProps {
activePanel: string
setActivePanel: (s: string) => void
}
import useNavigation, { routes } from './hooks/useNavigation'
const useStyles = makeStyles((theme) => ({
offset: theme.mixins.toolbar,
title: { flexGrow: 1 },
}))
function getAvailableDestinations(activePanel: string): string[] {
return Object.values(panelIdentifiers).filter(
(label) => activePanel !== label,
)
const routePrettyNames = {
[routes.SETTINGS]: 'Settings',
[routes.FEEDS]: 'Your feeds',
}
export default function NavigationBar(
props: NavigationBarProps,
): FunctionComponent<NavigationBarProps> {
const { activePanel, setActivePanel } = props
export default function NavigationBar(): FunctionComponent {
const { location, navigate } = useNavigation()
const classes = useStyles()
const availableDestinations = getAvailableDestinations(activePanel)
return (
<>
<AppBar position="fixed">
@ -40,17 +31,22 @@ export default function NavigationBar(
variant="h6"
className={classes.title}
>
{readablePanelIdentifiers[activePanel]}
{routePrettyNames[location]}
</Typography>
{availableDestinations.map((panelIdentifier) => (
<Button
key={`navigation_${panelIdentifier}`}
color="inherit"
onClick={() => setActivePanel(panelIdentifier)}
>
{readablePanelIdentifiers[panelIdentifier]}
</Button>
))}
<Button
key={'navigation-feeds'}
color="inherit"
onClick={() => navigate(routes.FEEDS)}
>
{routePrettyNames[routes.FEEDS]}
</Button>
<Button
key={'navigation-settings'}
color="inherit"
onClick={() => navigate(routes.SETTINGS)}
>
{routePrettyNames[routes.SETTINGS]}
</Button>
</Toolbar>
</AppBar>
<div className={classes.offset} />

View file

@ -9,12 +9,7 @@ import Button from '@material-ui/core/Button'
import CheckCircleOutlineIcon from '@material-ui/icons/CheckCircleOutline'
import ErrorOutlineIcon from '@material-ui/icons/ErrorOutline'
import { storeSettings } from './utils/persistence'
interface Props {
feedUrls: string[]
setFeedUrls: (s: string[]) => void
}
import useSettings from './hooks/useSettings'
const useStyles = makeStyles({
urlCard: {
@ -32,9 +27,10 @@ function isValidUrl(url: string): boolean {
return urlPattern.test(url)
}
export default function SettingsPanel(props: Props): FunctionComponent<Props> {
const { feedUrls, setFeedUrls } = props
const [feedUrlsForm, setFeedUrlsForm] = useState(feedUrls)
export default function SettingsPanel(): FunctionComponent {
const { getSettings, setSettings } = useSettings()
const settings = getSettings()
const [feedUrlsForm, setFeedUrlsForm] = useState(settings.feedUrls)
const classes = useStyles()
const urlCards = feedUrlsForm.map((url) => (
@ -72,9 +68,8 @@ export default function SettingsPanel(props: Props): FunctionComponent<Props> {
color="primary"
onClick={() => {
const validUrls = feedUrlsForm.filter(isValidUrl)
setFeedUrls(validUrls)
setSettings<string[]>('feedUrls', validUrls)
setFeedUrlsForm(validUrls)
storeSettings({ feedUrls: validUrls })
}}
>
Save

View file

@ -0,0 +1,26 @@
interface LocalStorageHookReturnType {
getValue: <T>(k: string) => T
setValue: <T>(k: string, v: T) => void
}
/*
* Encapsulates Local Storage interactions.
*/
export default function useLocalStorage(
{ isJSON }: { isJSON: boolean } = { isJSON: false },
): LocalStorageHookReturnType {
if (!window.localStorage) throw new Error('Local Storage is not available')
return {
getValue: <T>(key: string): T => {
const raw = window.localStorage.getItem(key)
return (isJSON ? JSON.parse(raw) : raw) as T
},
setValue: <T>(key: string, value: T) => {
window.localStorage.setItem(
key,
(isJSON ? JSON.stringify(value) : value) as string,
)
},
}
}

View file

@ -0,0 +1,54 @@
import { ComponentChildren, FunctionComponent, createContext } from 'preact'
import { useCallback, useContext, useState } from 'preact/hooks'
interface INavigationContext {
location: string
navigate: (url: string) => void
}
const NavigationContext = createContext(null)
export default function useNavigation(): INavigationContext {
const context = useContext(NavigationContext)
if (!context) throw Error('Invalid navigation context')
return context
}
export const routes = {
FEEDS: '/feeds/',
SETTINGS: '/settings/',
}
export function NavigationProvider({
children,
}: {
children: ComponentChildren
}): FunctionComponent<{ children: ComponentChildren }> {
const [location, setLocation] = useState<string>(window.location.pathname)
const navigate = useCallback(
(url: string) => {
window.history.pushState({}, '', url)
setLocation(url)
},
[setLocation],
)
const suffixedLocation = location.endsWith('/') ? location : `${location}/`
if (suffixedLocation !== location) {
window.history.replaceState({}, '', suffixedLocation)
setLocation(suffixedLocation)
}
if (!Object.values(routes).includes(suffixedLocation))
navigate(routes.FEEDS)
return (
<NavigationContext.Provider value={{ location, navigate }}>
{children}
</NavigationContext.Provider>
)
}

97
src/hooks/useRSSFeeds.ts Normal file
View file

@ -0,0 +1,97 @@
import { parseFeed } from 'htmlparser2'
import { useQueries } from 'react-query'
import { Feed } from '../types'
import useLocalStorage from './useLocalStorage'
function mergeFeeds(first, second) {
// Assuming `second` is newer.
const seen = new Set(first.items.map((item) => item.url))
const mergedItems = second.items.reduce(
(updatedItems, item) => {
if (!seen.has(item.url)) {
updatedItems.push(item)
seen.add(item.url)
}
return updatedItems
},
[...first.items],
)
return {
...second,
items: mergedItems,
}
}
function processFeedXML(feed): Feed {
return {
title: feed.title,
lastPull: String(Date.now()),
items: feed.items.reduce((items, feedItem) => {
items.push({
title: feedItem.title,
url: feedItem.link,
published: new Date(feedItem.pubDate),
})
return items
}, []),
}
}
async function fetchFeed(
url: string,
persistedData: Feed | null,
): Promise<Feed> {
const response = await fetch(`/.netlify/functions/rss-proxy?url=${url}`)
const responseData = await response.text()
try {
const newFeedData = parseFeed(responseData)
const newFeed = processFeedXML(newFeedData)
const mergedFeeds = persistedData
? mergeFeeds(persistedData, newFeed)
: newFeed
return mergedFeeds
} catch (e) {
// eslint-disable-next-line no-console
console.error(e)
}
return persistedData
}
export default function useRSSFeeds(urls: string[]): { feeds: Feed[] } {
const { getValue, setValue } = useLocalStorage({ isJSON: true })
const queries = useQueries(
urls.map((feedUrl: string) => {
const localStorageKey = `feed_${feedUrl}`
const persistedData = getValue<Feed>(localStorageKey)
return {
queryKey: ['feed', feedUrl],
queryFn: () => fetchFeed(feedUrl, persistedData),
staleTime: 30000,
onSuccess: (data: Feed) => {
setValue(localStorageKey, data)
},
initialData: persistedData ?? undefined,
initialDataUpdatedAt:
Number(persistedData?.lastPull) ?? undefined,
}
}),
)
const fetchedFeeds = queries.reduce((fetchedFeeds: Feed[], current) => {
if (current.isSuccess) fetchedFeeds.push(current.data)
return fetchedFeeds
}, [])
return { feeds: fetchedFeeds }
}

25
src/hooks/useSettings.ts Normal file
View file

@ -0,0 +1,25 @@
import { Settings } from '../types'
import useLocalStorage from './useLocalStorage'
const defaultSettings = {
feedUrls: [],
}
export default function useSettings(): {
getSettings: () => Settings
setSettings: <T>(k: string, value: T) => void
} {
const { getValue, setValue } = useLocalStorage({ isJSON: true })
const getSettings = (): Settings =>
getValue<Settings>('settings') ?? defaultSettings
const setSettings = <T>(key: string, value: T) => {
const current = getSettings()
setValue<Settings>('settings', { ...current, [key]: value })
}
return { getSettings, setSettings }
}

View file

@ -1,3 +1,4 @@
<!DOCTYPE html>
<html>
<head>
<title>🏴‍☠️ Yet Another RSS Reader 🏴‍☠️</title>

View file

@ -2,8 +2,19 @@ if (process.env.NODE_ENV === 'development') {
require('preact/debug')
}
import { QueryClient, QueryClientProvider } from 'react-query'
import { render } from 'preact'
import { NavigationProvider } from './hooks/useNavigation'
import App from './App'
render(<App />, document.getElementById('app'))
const queryClient = new QueryClient()
render(
<NavigationProvider>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</NavigationProvider>,
document.getElementById('app'),
)

View file

@ -1,97 +0,0 @@
import { parseFeed } from 'htmlparser2'
import type { Feed } from '../types'
import { restoreRssData, storeRssData } from './persistence'
function processFeedXML(feed) {
return {
title: feed.title,
lastPull: Date.now(),
items: feed.items.reduce((items, feedItem) => {
items.push({
title: feedItem.title,
url: feedItem.link,
published: new Date(feedItem.pubDate),
})
return items
}, []),
}
}
function getRefetchThreshold() {
const refetchThreshold = new Date()
refetchThreshold.setMinutes(refetchThreshold.getMinutes() - 10)
return refetchThreshold
}
function mergeFeeds(first, second) {
// Assuming `second` is newer.
const seen = new Set(first.items.map((item) => item.url))
const mergedItems = second.items.reduce(
(updatedItems, item) => {
if (!seen.has(item.url)) {
updatedItems.push(item)
seen.add(item.url)
}
return updatedItems
},
[...first.items],
)
return {
...second,
items: mergedItems,
}
}
/*
* Fetches RSS feeds from the given url list. If feed data exists in
* localStorage, it is used as a basis for the final list and any new
* items are added on top. If the cache has been updated in the past
* 10 minutes, no network fetch happens.
*
* Returns a list of Item.
*/
export default async function fetchFeeds(
feedUrls: string[],
forceRefetch = false,
): Feed[] {
const feeds = await Promise.all(
feedUrls.map(async (url: string) => {
const storedFeedData = restoreRssData(url)
// Skip refetch if not stale / not forced
const lastPull = storedFeedData?.lastPull
if (!forceRefetch && lastPull > getRefetchThreshold())
return storedFeedData
const response = await fetch(
`/.netlify/functions/rss-proxy?url=${url}`,
)
if (!response.ok) return storedFeedData
const responseData = await response.text()
try {
const newFeedData = parseFeed(responseData)
const newFeed = processFeedXML(newFeedData)
const mergedFeeds = storedFeedData
? mergeFeeds(storedFeedData, newFeed)
: newFeed
storeRssData(url, mergedFeeds)
return mergedFeeds
} catch (e) {
// eslint-disable-next-line no-console
console.error(e)
}
return storedFeedData
}),
)
return feeds.filter((feed) => feed)
}

View file

@ -1,38 +0,0 @@
import type { RSSData, Settings } from '../types'
const SETTINGS_KEY = 'settings'
const RSS_DATA_KEY_PREFIX = 'savedItems_'
function cacheKeyFromURL(url: string): string {
return url.replace(/^https?:\/\//, '').replace(/\/$/, '')
}
function storeToLocal(key: string, data): void {
window.localStorage.setItem(key, JSON.stringify(data))
}
function restoreFromLocal(key: string) {
const restored = window.localStorage.getItem(key)
try {
return JSON.parse(restored)
} catch (e) {
return null
}
}
export function storeSettings(settings: Settings): void {
return storeToLocal(SETTINGS_KEY, settings)
}
export function restoreSettings(): Settings {
return restoreFromLocal(SETTINGS_KEY)
}
export function storeRssData(url: string, items: Item[]): void {
const key = RSS_DATA_KEY_PREFIX + cacheKeyFromURL(url)
storeToLocal(key, items)
}
export function restoreRssData(url: string): RSSData {
const key = RSS_DATA_KEY_PREFIX + cacheKeyFromURL(url)
return restoreFromLocal(key)
}

View file

@ -1,73 +0,0 @@
import { useReducer, useRef } from 'preact/hooks'
import type { State } from '../types'
import { panelIdentifiers } from '../constants'
export const SET_PANEL_IDENTIFIER = 'setPanelIdentifier'
export const SET_FEED_URLS = 'setFeedUrls'
export const SET_FEEDS = 'setFeeds'
export const defaultState = {
loaded: false,
activePanel: panelIdentifiers.FEEDS,
feeds: [],
feedUrls: [],
}
function reducer(state, action) {
switch (action.type) {
case SET_PANEL_IDENTIFIER:
return { ...state, activePanel: action.payload.panelIdentifier }
case SET_FEED_URLS:
return { ...state, loaded: true, feedUrls: action.payload.feedUrls }
case SET_FEEDS:
return { ...state, feeds: action.payload.feeds }
default:
return state
}
}
function setActivePanel(panelIdentifier: string) {
return {
type: SET_PANEL_IDENTIFIER,
payload: { panelIdentifier },
}
}
function setFeedUrls(feedUrls: string[]) {
return {
type: SET_FEED_URLS,
payload: { feedUrls },
}
}
function setFeeds(feeds) {
return {
type: SET_FEEDS,
payload: { feeds },
}
}
type Action =
| ReturnType<setFeeds>
| ReturnType<setFeedUrls>
| ReturnType<setActivePanel>
interface ActionSet {
[key: string]: Action
}
export default function useAppState(): [State, ActionSet] {
const [state, dispatch] = useReducer(reducer, defaultState)
const actions = {
setActivePanel: (panelIdentifier) =>
dispatch(setActivePanel(panelIdentifier)),
setFeedUrls: (feedUrls) => dispatch(setFeedUrls(feedUrls)),
setFeeds: (feeds) => dispatch(setFeeds(feeds)),
}
const stableActions = useRef(actions)
return [state, stableActions.current]
}

View file

@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "es2015",
"target": "es2017",
"jsx": "react-jsx",
"jsxImportSource": "preact",
"moduleResolution": "node"

117
yarn.lock
View file

@ -1328,6 +1328,15 @@ __metadata:
languageName: node
linkType: hard
"@babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.6.2, @babel/runtime@npm:^7.7.2":
version: 7.17.2
resolution: "@babel/runtime@npm:7.17.2"
dependencies:
regenerator-runtime: ^0.13.4
checksum: a48702d271ecc59c09c397856407afa29ff980ab537b3da58eeee1aeaa0f545402d340a1680c9af58aec94dfdcbccfb6abb211991b74686a86d03d3f6956cacd
languageName: node
linkType: hard
"@babel/runtime@npm:^7.8.4":
version: 7.14.8
resolution: "@babel/runtime@npm:7.14.8"
@ -5095,6 +5104,13 @@ __metadata:
languageName: node
linkType: hard
"big-integer@npm:^1.6.16":
version: 1.6.51
resolution: "big-integer@npm:1.6.51"
checksum: 3d444173d1b2e20747e2c175568bedeebd8315b0637ea95d75fd27830d3b8e8ba36c6af40374f36bdaea7b5de376dcada1b07587cb2a79a928fccdb6e6e3c518
languageName: node
linkType: hard
"big.js@npm:^5.2.2":
version: 5.2.2
resolution: "big.js@npm:5.2.2"
@ -5245,6 +5261,22 @@ __metadata:
languageName: node
linkType: hard
"broadcast-channel@npm:^3.4.1":
version: 3.7.0
resolution: "broadcast-channel@npm:3.7.0"
dependencies:
"@babel/runtime": ^7.7.2
detect-node: ^2.1.0
js-sha3: 0.8.0
microseconds: 0.2.0
nano-time: 1.0.0
oblivious-set: 1.0.0
rimraf: 3.0.2
unload: 2.2.0
checksum: 803794c48dcce7f03aca69797430bd8b1c4cfd70b7de22079cd89567eeffaa126a1db98c7c2d86af8131d9bb41ed367c0fef96dfb446151c927b831572c621fc
languageName: node
linkType: hard
"brorand@npm:^1.0.1, brorand@npm:^1.1.0":
version: 1.1.0
resolution: "brorand@npm:1.1.0"
@ -7210,6 +7242,13 @@ __metadata:
languageName: node
linkType: hard
"detect-node@npm:^2.0.4, detect-node@npm:^2.1.0":
version: 2.1.0
resolution: "detect-node@npm:2.1.0"
checksum: 832184ec458353e41533ac9c622f16c19f7c02d8b10c303dfd3a756f56be93e903616c0bb2d4226183c9351c15fc0b3dba41a17a2308262afabcfa3776e6ae6e
languageName: node
linkType: hard
"detective-amd@npm:^3.0.1":
version: 3.1.0
resolution: "detective-amd@npm:3.1.0"
@ -11469,6 +11508,13 @@ __metadata:
languageName: node
linkType: hard
"js-sha3@npm:0.8.0":
version: 0.8.0
resolution: "js-sha3@npm:0.8.0"
checksum: 75df77c1fc266973f06cce8309ce010e9e9f07ec35ab12022ed29b7f0d9c8757f5a73e1b35aa24840dced0dea7059085aa143d817aea9e188e2a80d569d9adce
languageName: node
linkType: hard
"js-string-escape@npm:^1.0.1":
version: 1.0.1
resolution: "js-string-escape@npm:1.0.1"
@ -12510,6 +12556,16 @@ __metadata:
languageName: node
linkType: hard
"match-sorter@npm:^6.0.2":
version: 6.3.1
resolution: "match-sorter@npm:6.3.1"
dependencies:
"@babel/runtime": ^7.12.5
remove-accents: 0.4.2
checksum: a4b02b676ac4ce64a89a091539ee4a70a802684713bcf06f2b70787927f510fe8a2adc849f9288857a90906083ad303467e530e8723b4a9756df9994fc164550
languageName: node
linkType: hard
"maxstache-stream@npm:^1.0.0":
version: 1.0.4
resolution: "maxstache-stream@npm:1.0.4"
@ -12659,6 +12715,13 @@ __metadata:
languageName: node
linkType: hard
"microseconds@npm:0.2.0":
version: 0.2.0
resolution: "microseconds@npm:0.2.0"
checksum: 22bfa8553f92c7d95afff6de0aeb2aecf750680d41b8c72b02098ccc5bbbb0a384380ff539292dbd3788f5dfc298682f9d38a2b4c101f5ee2c9471d53934c5fa
languageName: node
linkType: hard
"miller-rabin@npm:^4.0.0":
version: 4.0.1
resolution: "miller-rabin@npm:4.0.1"
@ -12999,6 +13062,15 @@ __metadata:
languageName: node
linkType: hard
"nano-time@npm:1.0.0":
version: 1.0.0
resolution: "nano-time@npm:1.0.0"
dependencies:
big-integer: ^1.6.16
checksum: eef8548546cc1020625f8e44751a7263e9eddf0412a6a1a6c80a8d2be2ea7973622804a977cdfe796807b85b20ff6c8ba340e8dd20effcc7078193ed5edbb5d4
languageName: node
linkType: hard
"nanocolors@npm:^0.1.12":
version: 0.1.12
resolution: "nanocolors@npm:0.1.12"
@ -13621,6 +13693,13 @@ __metadata:
languageName: node
linkType: hard
"oblivious-set@npm:1.0.0":
version: 1.0.0
resolution: "oblivious-set@npm:1.0.0"
checksum: f31740ea9c3a8242ad2324e4ebb9a35359fbc2e6e7131731a0fc1c8b7b1238eb07e4c8c631a38535243a7b8e3042b7e89f7dc2a95d2989afd6f80bd5793b0aab
languageName: node
linkType: hard
"oclif-plugin-completion@npm:^0.6.0":
version: 0.6.0
resolution: "oclif-plugin-completion@npm:0.6.0"
@ -15421,6 +15500,24 @@ __metadata:
languageName: node
linkType: hard
"react-query@npm:^3.34.16":
version: 3.34.16
resolution: "react-query@npm:3.34.16"
dependencies:
"@babel/runtime": ^7.5.5
broadcast-channel: ^3.4.1
match-sorter: ^6.0.2
peerDependencies:
react: ^16.8.0 || ^17.0.0
peerDependenciesMeta:
react-dom:
optional: true
react-native:
optional: true
checksum: 77f5ee7279fa82642d5210b785f7b9eccc6e81c5b27b59bf112101b14e90737efeb8b73f9597bff56a08c5792ec5fa6889e14913aa196f5086ff4faeb077abd8
languageName: node
linkType: hard
"react-refresh@npm:^0.9.0":
version: 0.9.0
resolution: "react-refresh@npm:0.9.0"
@ -15677,6 +15774,13 @@ __metadata:
languageName: node
linkType: hard
"remove-accents@npm:0.4.2":
version: 0.4.2
resolution: "remove-accents@npm:0.4.2"
checksum: 84a6988555dea24115e2d1954db99509588d43fe55a1590f0b5894802776f7b488b3151c37ceb9e4f4b646f26b80b7325dcea2fae58bc3865df146e1fa606711
languageName: node
linkType: hard
"remove-trailing-separator@npm:^1.0.1":
version: 1.1.0
resolution: "remove-trailing-separator@npm:1.1.0"
@ -15935,7 +16039,7 @@ __metadata:
languageName: node
linkType: hard
"rimraf@npm:^3.0.0, rimraf@npm:^3.0.2":
"rimraf@npm:3.0.2, rimraf@npm:^3.0.0, rimraf@npm:^3.0.2":
version: 3.0.2
resolution: "rimraf@npm:3.0.2"
dependencies:
@ -16041,6 +16145,7 @@ __metadata:
parcel: ^2.0.0
preact: ^10.5.14
prettier: ^2.3.2
react-query: ^3.34.16
typescript: ^4.3.5
languageName: unknown
linkType: soft
@ -17912,6 +18017,16 @@ typescript@^3.9.7:
languageName: node
linkType: hard
"unload@npm:2.2.0":
version: 2.2.0
resolution: "unload@npm:2.2.0"
dependencies:
"@babel/runtime": ^7.6.2
detect-node: ^2.0.4
checksum: 88ba950c5ff83ab4f9bbd8f63bbf19ba09687ed3c434efd43b7338cc595bc574df8f9b155ee6eee7a435de3d3a4a226726988428977a68ba4907045f1fac5d41
languageName: node
linkType: hard
"unpipe@npm:1.0.0, unpipe@npm:~1.0.0":
version: 1.0.0
resolution: "unpipe@npm:1.0.0"