diff --git a/package-lock.json b/package-lock.json index 68e57e5..a17a30f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@asteasolutions/zod-to-openapi": "^8.5.0", "@emotion/css": "^11.13.5", "@sentry/cloudflare": "^10.55.0", + "@tanstack/react-virtual": "^3.14.2", "algoliasearch": "^5.53.0", "hono": "^4.12.23", "is-deflate": "^1.0.0", @@ -20,6 +21,7 @@ "react": "^19.2.6", "react-dom": "^19.2.6", "semver": "^7.7.4", + "spdx-license-ids": "^3.0.23", "swagger-ui-react": "^5.32.6", "zod": "^4.4.3" }, @@ -34,6 +36,7 @@ "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", "@types/semver": "^7.7.1", + "@types/spdx-license-ids": "^3.0.0", "@types/swagger-ui-react": "^5.18.0", "eslint": "^10.4.0", "eslint-config-prettier": "^10.1.8", @@ -2868,6 +2871,33 @@ "node": ">=12.20.0" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz", + "integrity": "sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.0.tgz", + "integrity": "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@trivago/prettier-plugin-sort-imports": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-6.0.2.tgz", @@ -3041,6 +3071,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/spdx-license-ids": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/spdx-license-ids/-/spdx-license-ids-3.0.0.tgz", + "integrity": "sha512-x+bDiv6h46IR5lUipX/ndSWlOrwLxTFpTelLGRDcYm3vrELDaMpKFXIrIuxmpPl/I+IzMeOPBPWsribCFqmgBg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/swagger-ui-react": { "version": "5.18.0", "resolved": "https://registry.npmjs.org/@types/swagger-ui-react/-/swagger-ui-react-5.18.0.tgz", @@ -6853,10 +6890,10 @@ } }, "node_modules/spdx-license-ids": { - "version": "3.0.16", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.16.tgz", - "integrity": "sha512-eWN+LnM3GR6gPu35WxNgbGl8rmY1AEmoMDvL/QD6zYmPWgywxWqJWNdLGT+ke8dKNWrcYgYjPpG5gbTfghP8rw==", - "dev": true + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "license": "CC0-1.0" }, "node_modules/sprintf-js": { "version": "1.0.3", @@ -9576,6 +9613,19 @@ "apg-lite": "^1.0.4" } }, + "@tanstack/react-virtual": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz", + "integrity": "sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==", + "requires": { + "@tanstack/virtual-core": "3.17.0" + } + }, + "@tanstack/virtual-core": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.0.tgz", + "integrity": "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==" + }, "@trivago/prettier-plugin-sort-imports": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-6.0.2.tgz", @@ -9711,6 +9761,12 @@ "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", "dev": true }, + "@types/spdx-license-ids": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/spdx-license-ids/-/spdx-license-ids-3.0.0.tgz", + "integrity": "sha512-x+bDiv6h46IR5lUipX/ndSWlOrwLxTFpTelLGRDcYm3vrELDaMpKFXIrIuxmpPl/I+IzMeOPBPWsribCFqmgBg==", + "dev": true + }, "@types/swagger-ui-react": { "version": "5.18.0", "resolved": "https://registry.npmjs.org/@types/swagger-ui-react/-/swagger-ui-react-5.18.0.tgz", @@ -12110,10 +12166,9 @@ } }, "spdx-license-ids": { - "version": "3.0.16", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.16.tgz", - "integrity": "sha512-eWN+LnM3GR6gPu35WxNgbGl8rmY1AEmoMDvL/QD6zYmPWgywxWqJWNdLGT+ke8dKNWrcYgYjPpG5gbTfghP8rw==", - "dev": true + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==" }, "sprintf-js": { "version": "1.0.3", diff --git a/package.json b/package.json index 0a9379e..bc2270d 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@asteasolutions/zod-to-openapi": "^8.5.0", "@emotion/css": "^11.13.5", "@sentry/cloudflare": "^10.55.0", + "@tanstack/react-virtual": "^3.14.2", "algoliasearch": "^5.53.0", "hono": "^4.12.23", "is-deflate": "^1.0.0", @@ -42,6 +43,7 @@ "react": "^19.2.6", "react-dom": "^19.2.6", "semver": "^7.7.4", + "spdx-license-ids": "^3.0.23", "swagger-ui-react": "^5.32.6", "zod": "^4.4.3" }, @@ -56,6 +58,7 @@ "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", "@types/semver": "^7.7.1", + "@types/spdx-license-ids": "^3.0.0", "@types/swagger-ui-react": "^5.18.0", "eslint": "^10.4.0", "eslint-config-prettier": "^10.1.8", diff --git a/src/routes/library.page.tsx b/src/routes/library.page.tsx new file mode 100644 index 0000000..5a2b084 --- /dev/null +++ b/src/routes/library.page.tsx @@ -0,0 +1,206 @@ +import { css } from '@emotion/css'; +import spdxLicenseIds from 'spdx-license-ids'; + +import { required } from '../utils/filter.ts'; +import Files from '../utils/jsx/islands/files.tsx'; +import theme from '../utils/theme.ts'; + +import type { + LibraryResponse, + LibraryVersionResponse, +} from './library.schema.ts'; + +const libraryRepo = (library: LibraryResponse) => { + const raw = + library.repository?.url || + (library.autoupdate?.source === 'git' + ? library.autoupdate.target + : undefined); + if (!raw) return null; + + const parsed = raw.match( + /^(?:https:\/\/|git@)?(?:www\.)?github\.com[:/]([^/]+)\/([^/]+)$/, + ); + if (!parsed) return null; + + const [, owner, name] = parsed; + if (!owner || !name) return null; + + return { + owner, + name: name.replace(/\.git$/, ''), + }; +}; + +const styles = { + header: css` + display: flex; + flex-direction: column; + gap: ${theme.spacing(2)}; + margin: ${theme.spacing(-2, 0, 2)}; + padding: ${theme.spacing(2, 0)}; + position: relative; + isolation: isolate; + z-index: 1; + + &::before { + content: ''; + position: absolute; + width: 100vw; + left: 50%; + transform: translateX(-50%); + background: ${theme.background.header}; + top: 0; + bottom: 0; + z-index: -1; + } + `, + row: css` + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: ${theme.spacing(1, 2)}; + `, + name: css` + margin: 0; + font-size: ${theme.font.heading.size}; + font-weight: ${theme.font.heading.weight}; + `, + description: css` + margin: 0; + font-size: ${theme.font.large.size}; + font-weight: ${theme.font.large.weight}; + `, + link: css` + margin: 0; + font-size: ${theme.font.body.size}; + font-weight: ${theme.font.body.weight}; + + a { + color: ${theme.text.brand}; + text-decoration: underline; + + &:hover { + text-decoration: none; + } + } + `, + keywords: css` + margin: 0; + font-size: ${theme.font.small.size}; + font-weight: ${theme.font.small.weight}; + color: ${theme.text.secondary}; + `, +}; + +/** + * /library/:version page component. + * + * @param props Page props. + * @param props.library Library data. + * @param props.version Library version data. + */ +export default ({ + library, + version, +}: { + library: LibraryResponse; + version: LibraryVersionResponse; +}) => { + if (!required(library, 'name', 'description', 'versions')) { + throw new Error('Library data is missing required fields'); + } + + if (!required(version, 'version', 'files', 'sri')) { + throw new Error('Library version data is missing required fields'); + } + + const repo = libraryRepo(library); + + return ( + <> +
+
+

{library.name}

+

{library.description}

+
+ +
+ {library.license && ( +

+ {spdxLicenseIds.includes(library.license) ? ( + + {library.license} + + ) : ( + library.license + )}{' '} + licensed +

+ )} + + {library.autoupdate?.source === 'npm' && ( +

+ + npm package + +

+ )} + + {repo && ( +

+ + GitHub repository + +

+ )} + + {library.homepage && ( +

+ + {library.homepage} + +

+ )} +
+ + {!!library.keywords?.length && ( +

+ Keywords:{' '} + {library.keywords.map((keyword, index, arr) => ( + + {keyword} + {index < arr.length - 1 && ', '} + + ))} +

+ )} +
+ + + + ); +}; diff --git a/src/routes/library.spec.ts b/src/routes/library.spec.ts index 79f32a4..485cf33 100644 --- a/src/routes/library.spec.ts +++ b/src/routes/library.spec.ts @@ -537,7 +537,7 @@ describe('/libraries/:library', () => { describe('Requesting human response (?output=human)', () => { // Fetch the endpoint const path = '/libraries/backbone.js?output=human'; - const response = beforeRequest(path); + const response = beforeRequest(path, { redirect: 'manual' }); // Test the endpoint testCors(path, response); @@ -546,7 +546,12 @@ describe('/libraries/:library', () => { 'public, max-age=21600', ); // 6 hours }); - testHuman(response); + it('returns a redirect to the latest version', () => { + expect(response.status).to.eq(302); + expect(response.headers.get('Location')).to.match( + /^\/libraries\/backbone\.js\/[^/]+$/, + ); + }); }); describe('Requesting a field (?fields=assets)', () => { diff --git a/src/routes/library.ts b/src/routes/library.ts index fe831c8..7ce67b8 100644 --- a/src/routes/library.ts +++ b/src/routes/library.ts @@ -2,6 +2,7 @@ import type { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; import type { Context, Hono } from 'hono'; import * as z from 'zod'; +import event from '../utils/event.ts'; import files from '../utils/files.ts'; import filter from '../utils/filter.ts'; import { @@ -11,9 +12,10 @@ import { libraryVersions, } from '../utils/metadata.ts'; import { queryCheck } from '../utils/query.ts'; -import respond, { notFound, withCache } from '../utils/respond.ts'; +import respond, { isHuman, notFound, withCache } from '../utils/respond.ts'; import { errorResponseSchema } from './errors.schema.ts'; +import LibraryPage from './library.page.tsx'; import { type LibraryResponse, type LibraryVersionResponse, @@ -129,7 +131,10 @@ const handleGetLibraryVersion = async (ctx: Context) => { withCache(ctx, 355 * 24 * 60 * 60, true); // Send the response - return respond(ctx, response); + return respond(ctx, response, async ({ data }) => { + const versions = await libraryVersions(lib.name); + return LibraryPage({ library: { ...lib, versions }, version: data }); + }); }; /** @@ -239,6 +244,14 @@ const handleGetLibrary = async (ctx: Context) => { // Set a 6 hour life on this response withCache(ctx, 6 * 60 * 60); + // Redirect to the version endpoint if requesting a human-readable page + if (isHuman(ctx)) { + event('human-redirect', { ctx }); + return ctx.redirect( + `/libraries/${lib.name}/${lib.version}?output=human`, + ); + } + // Send the response return respond(ctx, response); }; diff --git a/src/utils/filter.ts b/src/utils/filter.ts index 3c9eb48..34d20ab 100644 --- a/src/utils/filter.ts +++ b/src/utils/filter.ts @@ -11,3 +11,22 @@ export default >( Object.fromEntries( Object.entries(source).filter(([key]) => filter(key)), ) as Partial; + +/** + * Check that an object has the specified keys, and that they are not undefined. Useful for checking a filtered object has fields before accessing them. + * + * @param source Source object to check for required keys. + * @param keys Keys to check for in the source object. + */ +export const required = < + T extends object, + const K extends readonly Extract[], +>( + source: T, + ...keys: K +): source is T & Required> => { + for (const key of keys) { + if (source[key] === undefined) return false; + } + return true; +}; diff --git a/src/utils/jsx/icons/check.tsx b/src/utils/jsx/icons/check.tsx new file mode 100644 index 0000000..03c2421 --- /dev/null +++ b/src/utils/jsx/icons/check.tsx @@ -0,0 +1,20 @@ +// https://heroicons.com `check` + +const IconCheck = ({ className }: { className?: string }) => ( + + + +); + +export default IconCheck; diff --git a/src/utils/jsx/icons/code.tsx b/src/utils/jsx/icons/code.tsx new file mode 100644 index 0000000..ef7fc94 --- /dev/null +++ b/src/utils/jsx/icons/code.tsx @@ -0,0 +1,20 @@ +// https://heroicons.com `code-bracket` + +const IconCode = ({ className }: { className?: string }) => ( + + + +); + +export default IconCode; diff --git a/src/utils/jsx/icons/link.tsx b/src/utils/jsx/icons/link.tsx new file mode 100644 index 0000000..e45a932 --- /dev/null +++ b/src/utils/jsx/icons/link.tsx @@ -0,0 +1,20 @@ +// https://heroicons.com `link` + +const IconLink = ({ className }: { className?: string }) => ( + + + +); + +export default IconLink; diff --git a/src/utils/jsx/icons/shield.tsx b/src/utils/jsx/icons/shield.tsx new file mode 100644 index 0000000..57ee32b --- /dev/null +++ b/src/utils/jsx/icons/shield.tsx @@ -0,0 +1,20 @@ +// https://heroicons.com `shield-check` + +const IconShield = ({ className }: { className?: string }) => ( + + + +); + +export default IconShield; diff --git a/src/utils/jsx/islands/files.tsx b/src/utils/jsx/islands/files.tsx new file mode 100644 index 0000000..79ab66a --- /dev/null +++ b/src/utils/jsx/islands/files.tsx @@ -0,0 +1,410 @@ +import { css, cx } from '@emotion/css'; +import { useWindowVirtualizer } from '@tanstack/react-virtual'; +import { + type CSSProperties, + type ComponentType, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import fileTypes from '../../files.ts'; +import theme from '../../theme.ts'; +import IconCheck from '../icons/check.tsx'; +import IconCode from '../icons/code.tsx'; +import IconLink from '../icons/link.tsx'; +import IconShield from '../icons/shield.tsx'; +import createIsland from '../island.tsx'; + +const styles = { + toolbar: css` + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: ${theme.spacing(1, 2)}; + `, + url: css` + color: ${theme.text.secondary}; + font-size: ${theme.font.small.size}; + font-weight: ${theme.font.small.weight}; + margin: 0 auto 0 0; + `, + dropdown: css` + display: flex; + align-items: center; + gap: ${theme.spacing(0.5)}; + + label { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + + select { + background: ${theme.background.navigation}; + color: ${theme.text.primary}; + cursor: pointer; + padding: ${theme.spacing(0.5, 1)}; + font-size: ${theme.font.body.size}; + font-weight: ${theme.font.body.weight}; + border: none; + border-radius: ${theme.radius}; + flex-shrink: 1; + min-width: ${theme.spacing(20)}; + transition: color ${theme.transition}; + + &:hover { + color: ${theme.text.brand}; + } + } + `, + list: css` + list-style: none; + padding: 0; + margin: 0; + position: relative; + margin: ${theme.spacing(2, 0, 0)}; + `, + file: css` + position: absolute; + top: 0; + left: 0; + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + padding: ${theme.spacing(0.5, 1)}; + background: ${theme.background.navigation}; + border-radius: ${theme.radius}; + + a { + font-size: ${theme.font.body.size}; + font-weight: ${theme.font.body.weight}; + color: ${theme.text.brand}; + text-decoration: underline; + + &:hover { + text-decoration: none; + } + } + `, + featured: css` + outline: 2px solid ${theme.background.brand}; + `, + buttons: css` + display: flex; + align-items: center; + gap: ${theme.spacing(0.5)}; + `, + copy: css` + background: none; + border: none; + cursor: pointer; + padding: ${theme.spacing(0.5)}; + line-height: 0; + color: ${theme.text.primary}; + transition: color ${theme.transition}; + + &:hover { + color: ${theme.text.brand}; + } + `, + icon: css` + width: ${theme.spacing(2.5)}; + height: ${theme.spacing(2.5)}; + `, +}; + +const Versions = ({ + name, + version, + versions, +}: { + name: string; + version: string; + versions: string[]; +}) => { + const [selected, setSelected] = useState(version); + return ( +
+ + +
+ ); +}; + +const Filter = ({ + files, + onChange, +}: { + files: string[]; + onChange: (files: string[]) => void; +}) => { + const [selected, setSelected] = useState(''); + + const [types, mapped] = useMemo(() => { + const found = new Set(); + + return [ + found, + files.map((file) => { + const ext = file.split('.').slice(-1)[0] || ''; + const type = + ext in fileTypes + ? fileTypes[ext as keyof typeof fileTypes] + : 'Other'; + found.add(type); + return { file, type }; + }), + ]; + }, [files]); + + return ( +
+ + +
+ ); +}; + +const Copy = ({ + text, + label, + icon: Icon, +}: { + text: string; + label: string; + icon: ComponentType<{ className?: string }>; +}) => { + const [copied, setCopied] = useState(false); + const timer = useRef | null>(null); + + const copy = () => { + navigator.clipboard.writeText(text); + setCopied(true); + if (timer.current) clearTimeout(timer.current); + timer.current = setTimeout(() => setCopied(false), 2000); + }; + + useEffect( + () => () => { + if (timer.current) clearTimeout(timer.current); + }, + [], + ); + + return ( + + ); +}; + +const File = ({ + name, + version, + file, + sri, + featured = false, + style, +}: { + name: string; + version: string; + file: string; + sri?: string; + featured?: boolean; + style?: CSSProperties; +}) => { + const integrity = sri ? ` integrity="${sri}" crossorigin="anonymous"` : ''; + + return ( +
  • + + {file} + + +
    + + + {file.endsWith('.js') && ( + `} + label="Copy `} + label="Copy