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.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