From 85b46fe949ecee9535b6f46bca36a2841d658ec1 Mon Sep 17 00:00:00 2001 From: Matteo Pellegrino Date: Sat, 7 Sep 2024 17:06:19 +0200 Subject: [PATCH 1/5] chore: Minor logs format change --- api/domain.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/domain.ts b/api/domain.ts index 1eb871438..37941bc58 100644 --- a/api/domain.ts +++ b/api/domain.ts @@ -101,10 +101,10 @@ export const PackageArray = new t.Type>( validation, either.fold( () => pipe( - `\n[>>>>] warn - PackageArray decode failure: ignoring the package + `[>>>>] warn - Package decode failure: ignoring ${JSON.stringify((input as Array)[i])} because - ${PathReporter.report(validation).join('\n')}\n`, + ${PathReporter.report(validation).join('\n')}`, console.error, () => option.none ), From c38ce2890110ba424647cf0359bde44db43d8762 Mon Sep 17 00:00:00 2001 From: Matteo Pellegrino Date: Sat, 7 Sep 2024 21:57:38 +0200 Subject: [PATCH 2/5] feat: Fallback on MDX ser. failure. Refactor package resolution code. --- api/package.ts | 105 +++++++++ api/packagesIndex.ts | 77 +++--- api/serializeReadme.ts | 33 --- pages/[packageName].tsx | 101 ++------ pages/[packageName]/v/[version].tsx | 352 +++++++++++----------------- pages/index.tsx | 52 ++-- pages/search.tsx | 50 ++-- 7 files changed, 332 insertions(+), 438 deletions(-) create mode 100644 api/package.ts delete mode 100644 api/serializeReadme.ts diff --git a/api/package.ts b/api/package.ts new file mode 100644 index 000000000..ca44c72b8 --- /dev/null +++ b/api/package.ts @@ -0,0 +1,105 @@ +import { array, either, nonEmptyArray, option, record } from "fp-ts"; +import { flow, pipe } from "fp-ts/lib/function"; +import { MDXRemoteSerializeResult } from "next-mdx-remote"; +import { serialize } from "next-mdx-remote/serialize"; +import remarkGfm from "remark-gfm"; +import { + GroupedByVersion, + OrderedByVersion, + Package, + PackageRepo, + PackagesIndex, +} from "./domain"; +import { fetchPackageRepo } from "./packageRepo"; + +export type PackageDetails = { + packageRepo: + | (PackageRepo & { + serializedReadme: MDXRemoteSerializeResult< + Record + > | null; + }) + | null; + versions: Array; +}; + +export async function resolvePackage( + packagesIndex: PackagesIndex, + packageName?: string, + version?: string +): Promise { + const packages = packagesIndex.packages.filter((p) => p.name === packageName); + const orderedPackages = pipe( + OrderedByVersion.decode(packages), + either.fold( + () => { + throw new Error("Failed to order packages by version"); + }, + (packages) => packages + ) + ); + + const orderedVersions = orderedPackages.map((p) => p.version); + + const currentVersion = pipe( + orderedPackages, + version ? array.findFirst((p) => p.version === version) : array.head, + option.toNullable + ); + + if (!currentVersion) { + // TODO: Omit entire package details page if version not found + throw new Error("Version not found"); + } + + const packageRepoEither = await fetchPackageRepo(currentVersion)(); + const packageRepo = pipe( + packageRepoEither, + either.mapLeft(console.error), + option.fromEither, + option.toNullable + ); + + if (!packageRepo) { + return { + packageRepo: null, + versions: orderedVersions, + }; + } + + // See https://github.com/espanso/hub-frontend/issues/6 + const fixLiteralHTML = (html: string) => + html + .replaceAll("
", "
") + .replaceAll("", "`") + .replaceAll("", "`"); + + const serializedReadme = await serialize(fixLiteralHTML(packageRepo.readme), { + mdxOptions: { + remarkPlugins: [remarkGfm], + }, + }).catch((e) => { + console.error(` + Failed to serialize readme for ${currentVersion.name} + ${e}`); + return null; + }); + + return { + packageRepo: { + ...packageRepo, + serializedReadme, + }, + versions: orderedVersions, + }; +} + +export const groupByVersion = (packages: Package[]) => + pipe( + packages, + GroupedByVersion.decode, + either.map( + flow(record.map(nonEmptyArray.head), Object.values) + ), + option.fromEither + ); diff --git a/api/packagesIndex.ts b/api/packagesIndex.ts index d48336add..f891d785c 100644 --- a/api/packagesIndex.ts +++ b/api/packagesIndex.ts @@ -1,7 +1,6 @@ import flatCache from "flat-cache"; -import { array, either, option, taskEither } from "fp-ts"; -import { flow, pipe } from "fp-ts/function"; -import { TaskEither } from "fp-ts/TaskEither"; +import { array, either } from "fp-ts"; +import { pipe } from "fp-ts/function"; import { PackagesIndex } from "./domain"; const PACKAGE_INDEX_URL = process.env.PACKAGE_INDEX_URL || ""; @@ -9,43 +8,39 @@ const PACKAGE_INDEX_URL = process.env.PACKAGE_INDEX_URL || ""; const PACKAGE_INDEX_CACHE_ID = "packagesIndex"; const CACHE_DIR = process.env.PACKAGE_INDEX_CACHE_DIR || undefined; -const fetchPackagesIndexInternal = (cache: flatCache.Cache) => - pipe( - cache.getKey(PACKAGE_INDEX_URL), - option.fromNullable, - option.fold( - () => - pipe( - taskEither.tryCatch(() => fetch(PACKAGE_INDEX_URL), either.toError), - taskEither.chain((response) => - taskEither.tryCatch(() => response.json(), either.toError) - ), - taskEither.chain( - flow( - PackagesIndex.decode, - either.mapLeft(either.toError), - taskEither.fromEither - ) - ), - taskEither.map((packagesIndex) => { - const noDummyPackage: PackagesIndex = { - ...packagesIndex, - packages: pipe( - packagesIndex.packages, - array.filter((p) => p.name !== "dummy-package") - ), - }; - cache.setKey(PACKAGE_INDEX_URL, noDummyPackage); - cache.save(); - return cache.getKey(PACKAGE_INDEX_URL); - }) - ), - taskEither.of - ), - taskEither.mapLeft(x => { console.error(x); return x }), +async function fetchPackagesIndex(url: string): Promise { + const response = await fetch(url); + const json = await response.json(); + + return pipe( + PackagesIndex.decode(json), + either.map((x) => ({ + ...x, + packages: pipe( + x.packages, + array.filter((p) => p.name !== "dummy-package") + ), + })), + either.fold( + (e) => Promise.reject(e), + (x) => Promise.resolve(x) + ) ); +} + +export async function getPackagesIndex(): Promise { + const cache = flatCache.load(PACKAGE_INDEX_CACHE_ID, CACHE_DIR); + const cachedPackagesIndex = cache.getKey(PACKAGE_INDEX_URL); + if (cachedPackagesIndex) { + return cachedPackagesIndex; + } + + const packagesIndex = await fetchPackagesIndex(PACKAGE_INDEX_URL); + + cache.setKey(PACKAGE_INDEX_URL, packagesIndex); + cache.save(); + return cache.getKey(PACKAGE_INDEX_URL); +} -export const fetchPackagesIndex: TaskEither = pipe( - flatCache.load(PACKAGE_INDEX_CACHE_ID, CACHE_DIR), - fetchPackagesIndexInternal -); +// export const getPackagesIndex: TaskEither = +// taskEither.tryCatch(fetchPackagesIndexOrCache, either.toError); diff --git a/api/serializeReadme.ts b/api/serializeReadme.ts deleted file mode 100644 index 96a536636..000000000 --- a/api/serializeReadme.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { taskEither } from "fp-ts"; -import { pipe } from "fp-ts/function"; -import { serialize } from "next-mdx-remote/serialize"; -import remarkGfm from "remark-gfm"; -import { PackageRepo } from "../api/domain"; - -// See https://github.com/espanso/hub-frontend/issues/6 -const fixLiteralHTML = (html: string) => - html - .replaceAll("
", "
") - .replaceAll("", "`") - .replaceAll("", "`"); - -export const serializeReadme = taskEither.chain((packageRepo: PackageRepo) => - pipe( - taskEither.tryCatch( - () => - serialize(fixLiteralHTML(packageRepo.readme), { - mdxOptions: { - remarkPlugins: [remarkGfm], - }, - }), - (r) => - new Error( - `${packageRepo.package.id}: Failing to serialize readme markdown: ${r}` - ) - ), - taskEither.map((serializedReadme) => ({ - ...packageRepo, - serializedReadme, - })) - ) -); diff --git a/pages/[packageName].tsx b/pages/[packageName].tsx index fb0c2f6d5..0bdf91ea6 100644 --- a/pages/[packageName].tsx +++ b/pages/[packageName].tsx @@ -1,84 +1,33 @@ -import { array, either, nonEmptyArray, option, task, taskEither } from "fp-ts"; -import { constant, flow, pipe } from "fp-ts/function"; -import { sequenceS } from "fp-ts/lib/Apply"; import { GetStaticPropsContext } from "next"; -import { OrderedByVersion } from "../api/domain"; -import { fetchPackageRepo } from "../api/packageRepo"; -import { fetchPackagesIndex } from "../api/packagesIndex"; -import { serializeReadme } from "../api/serializeReadme"; +import { resolvePackage } from "../api/package"; +import { getPackagesIndex } from "../api/packagesIndex"; import VersionedPackagePage, { Props } from "./[packageName]/v/[version]"; -export const getStaticProps = (context: GetStaticPropsContext) => - pipe( - fetchPackagesIndex, - taskEither.chain((packagesIndex) => - pipe( - packagesIndex.packages, - array.filter( - (p) => - context.params !== undefined && - p.name === context.params.packageName - ), - OrderedByVersion.decode, - either.mapLeft( - flow( - array.reduce("", (acc, curr) => `${acc}, ${curr.message}`), - either.toError - ) - ), - taskEither.fromEither - ) - ), - taskEither.chain((packages) => - sequenceS(taskEither.ApplyPar)({ - packageRepo: pipe( - packages, - array.head, - option.map(fetchPackageRepo), - taskEither.fromOption( - () => new Error(`Version ${context.params?.version} not found`) - ), - taskEither.flatten, - serializeReadme - ), - versions: pipe( - packages, - nonEmptyArray.map((p) => p.version), - taskEither.right - ), - }) - ), - task.map((props) => ({ - props: pipe( - props, - either.foldW( - () => ({ - packageRepo: option.none, - versions: [], - }), - (v) => ({ - packageRepo: option.some(v.packageRepo), - versions: v.versions, - }) - ) - ), - })) - )(); +export const getStaticProps = async (context: GetStaticPropsContext) => { + const packagesIndex = await getPackagesIndex(); + const packageName = context.params?.packageName; + const version = context.params?.version; -export const getStaticPaths = pipe( - fetchPackagesIndex, - task.map( - flow( - either.map((d) => d.packages), - either.map(array.map((p) => ({ params: { packageName: p.name } }))), - either.getOrElseW(constant([])) - ) - ), - task.map((paths) => ({ - paths, + const props = await resolvePackage( + packagesIndex, + Array.isArray(packageName) ? packageName[0] : packageName, + Array.isArray(version) ? version[0] : version + ); + + return { + props, + }; +}; + +export const getStaticPaths = async () => { + const packagesIndex = await getPackagesIndex(); + return { + paths: packagesIndex.packages.map((p) => ({ + params: { packageName: p.name }, + })), fallback: false, - })) -); + }; +}; const LatestPackagePage = (props: Props) => ; diff --git a/pages/[packageName]/v/[version].tsx b/pages/[packageName]/v/[version].tsx index e8d6808c8..d688c2a81 100644 --- a/pages/[packageName]/v/[version].tsx +++ b/pages/[packageName]/v/[version].tsx @@ -15,36 +15,21 @@ import { SideSheet, Text, } from "evergreen-ui"; -import { - array, - boolean, - either, - nonEmptyArray, - option, - string, - task, - taskEither, -} from "fp-ts"; +import { array, boolean, nonEmptyArray, option, string } from "fp-ts"; import { sequenceS } from "fp-ts/Apply"; -import { constant, flow, pipe, identity } from "fp-ts/function"; +import { constant, flow, identity, pipe } from "fp-ts/function"; import { NonEmptyArray } from "fp-ts/NonEmptyArray"; -import { Option } from "fp-ts/Option"; import { GetStaticPropsContext } from "next"; -import { MDXRemoteSerializeResult } from "next-mdx-remote"; import Head from "next/head"; import { useRouter } from "next/router"; import React from "react"; import { GithubURL } from "../../../api/assets"; -import { - FileAsString, - OrderedByVersion, - PackageRepo, -} from "../../../api/domain"; +import { FileAsString, PackageRepo } from "../../../api/domain"; +import { PackageDetails, resolvePackage } from "../../../api/package"; import { isFeatured } from "../../../api/packageFeatured"; -import { fetchPackageRepo } from "../../../api/packageRepo"; -import { fetchPackagesIndex } from "../../../api/packagesIndex"; +import { getPackagesIndex } from "../../../api/packagesIndex"; import { usePackageSearch } from "../../../api/search"; -import { serializeReadme } from "../../../api/serializeReadme"; +import { splitLines } from "../../../api/utils"; import { BetaBanner, CodeBlock, @@ -52,108 +37,46 @@ import { espansoTheme, FeaturedBadge, Footer, + GithubIcon, MDXRenderer, Navbar, NextjsLink, PackageNamer, + ShareButton, Stack, TabProps, TagBadgeGroup, - useTabs, useResponsive, - ShareButton, - GithubIcon + useTabs, } from "../../../components"; -import { splitLines } from "../../../api/utils" - -export type Props = { - packageRepo: Option< - PackageRepo & { - serializedReadme: MDXRemoteSerializeResult>; - } - >; - versions: Array; + +export type Props = PackageDetails; + +export const getStaticProps = async (context: GetStaticPropsContext) => { + const packagesIndex = await getPackagesIndex(); + const packageName = context.params?.packageName; + const version = context.params?.version; + + const props = await resolvePackage( + packagesIndex, + Array.isArray(packageName) ? packageName[0] : packageName, + Array.isArray(version) ? version[0] : version + ); + + return { + props, + }; }; -export const getStaticProps = (context: GetStaticPropsContext) => - pipe( - fetchPackagesIndex, - taskEither.chain((packagesIndex) => - pipe( - packagesIndex.packages, - array.filter( - (p) => - context.params !== undefined && - p.name === context.params.packageName - ), - OrderedByVersion.decode, - either.mapLeft( - flow( - array.reduce("", (acc, curr) => `${acc}, ${curr.message}`), - either.toError - ) - ), - taskEither.fromEither - ) - ), - taskEither.chain((packages) => - sequenceS(taskEither.ApplyPar)({ - packageRepo: pipe( - packages, - array.findFirst( - (p) => - context.params !== undefined && - p.version === context.params.version - ), - option.map(fetchPackageRepo), - taskEither.fromOption( - () => new Error(`Version ${context.params?.version} not found`) - ), - taskEither.flatten, - serializeReadme - ), - versions: pipe( - packages, - nonEmptyArray.map((p) => p.version), - taskEither.right - ), - }) - ), - task.map((props) => ({ - props: pipe( - props, - either.foldW( - () => ({ - packageRepo: option.none, - versions: [], - }), - (v) => ({ - packageRepo: option.some(v.packageRepo), - versions: v.versions, - }) - ) - ), - })) - )(); - -export const getStaticPaths = pipe( - fetchPackagesIndex, - task.map( - flow( - either.map((d) => d.packages), - either.map( - array.map((p) => ({ - params: { packageName: p.name, version: p.version }, - })) - ), - either.getOrElseW(constant([])) - ) - ), - task.map((paths) => ({ - paths, +export const getStaticPaths = async () => { + const packagesIndex = await getPackagesIndex(); + return { + paths: packagesIndex.packages.map((p) => ({ + params: { packageName: p.name, version: p.version }, + })), fallback: false, - })) -); + }; +}; const N_LINES_INCREMENTAL_CODE_BLOCK = 100; @@ -163,13 +86,13 @@ const YamlShowcase = (props: { files: NonEmptyArray }) => { nonEmptyArray.map((f) => ({ id: f.name, label: f.name, - render: () => - ( + , + content={pipe(f.content, splitLines(N_LINES_INCREMENTAL_CODE_BLOCK))} + /> + ), })) ); @@ -189,13 +112,13 @@ const YamlShowcaseMobile = (props: { files: NonEmptyArray }) => { nonEmptyArray.map((f) => ({ id: f.name, label: f.name, - render: () => - ( + , + content={pipe(f.content, splitLines(N_LINES_INCREMENTAL_CODE_BLOCK))} + /> + ), })) ); @@ -240,6 +163,7 @@ const VersionedPackagePage = (props: Props) => { const currentVersion = pipe( props.packageRepo, + option.fromNullable, option.map((p) => p.package.version) ); @@ -272,20 +196,21 @@ const VersionedPackagePage = (props: Props) => { - + {pipe( currentRepo.manifest.homepage, - option.map(url => + option.map((url) => ( - - ), + appearance="minimal" + color={espansoTheme.colors.gray700} + /> + + )), option.toNullable )} - {pipe( currentVersion, @@ -311,6 +236,7 @@ const VersionedPackagePage = (props: Props) => { option.chain((v) => pipe( props.packageRepo, + option.fromNullable, option.map((p) => p.package.name), option.map((n) => `/${n}/v/${v}`), option.map((pathname) => ({ pathname })) @@ -328,24 +254,24 @@ const VersionedPackagePage = (props: Props) => { )} - + 400, - tablet: () => 300, - mobile: () => 300, - })} - color={espansoTheme.colors.muted} + size={foldDevices({ + desktop: () => 400, + tablet: () => 300, + mobile: () => 300, + })} + color={espansoTheme.colors.muted} > 400, - tablet: () => 300, - mobile: () => 300, - })} - color={espansoTheme.colors.muted} + size={foldDevices({ + desktop: () => 400, + tablet: () => 300, + mobile: () => 300, + })} + color={espansoTheme.colors.muted} > By {currentRepo.manifest.author} @@ -385,7 +311,7 @@ const VersionedPackagePage = (props: Props) => { nonEmptyArray.fromArray, option.map(([current, latest]) => current === latest - ? option.some('') + ? option.some("") : option.some(` --version ${current}`) ), option.flatten, @@ -418,7 +344,7 @@ const VersionedPackagePage = (props: Props) => { ); const tabDescription = pipe( - props.packageRepo, + option.fromNullable(props.packageRepo), option.chain((packageRepo) => pipe( packageRepo.readme, @@ -430,18 +356,11 @@ const VersionedPackagePage = (props: Props) => { ) ), option.map((packageRepo) => ({ - mdxSource: option.some(packageRepo.serializedReadme), + mdxSource: option.fromNullable(packageRepo.serializedReadme), repositoryHomepage: pipe( packageRepo.manifest.homepage, - option.chain( - flow( - GithubURL.decode, - either.fold( - () => option.none, - (url) => option.some(url) - ) - ) - ) + GithubURL.decode, + option.fromEither ), })), option.chain(sequenceS(option.Apply)), @@ -456,17 +375,17 @@ const VersionedPackagePage = (props: Props) => { const tabSourceDesktop = pipe( props.packageRepo, - option.map((p) => p.packageYml), - option.map((files) => ( - - - - )), - option.map((content) => ({ + option.fromNullable, + option.map((packageRepo) => ({ id: "source", label: `Source`, icon: , - render: () => tabContentWrapper(content), + render: () => + tabContentWrapper( + + + + ), })) ); @@ -487,17 +406,17 @@ const VersionedPackagePage = (props: Props) => { const tabSourceMobile = pipe( props.packageRepo, - option.map((p) => p.packageYml), - option.map((files) => ( - - - - )), - option.map((content) => ({ + option.fromNullable, + option.map((packageRepo) => ({ id: "source", label: `Source`, icon: , - render: () => tabContentWrapper(content), + render: () => + tabContentWrapper( + + + + ), })) ); @@ -514,54 +433,53 @@ const VersionedPackagePage = (props: Props) => { searchPathname: "/search", }); - const packagePage = (currentRepo: PackageRepo) => { - const metaInfo = { - title: `${currentRepo.package.name} ${currentRepo.package.version} | Espanso Hub`, - description: `Paste in a terminal to install the \ -${currentRepo.package.name} package (v${currentRepo.package.version}): \ -${currentRepo.package.description}`, - }; - return ( - - - {metaInfo.title} - - - - - - - - - - - - - - - {header(currentRepo)} - {isDesktop ? tabsHeader : tabsHeaderMobile} - - - - - {isDesktop ? tabsContent : tabsContentMobile} - - - -