diff --git a/package-lock.json b/package-lock.json
index e1dad1b..01b2a98 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.50.0",
+ "@tanstack/react-virtual": "^3.13.26",
"algoliasearch": "^5.51.0",
"hono": "^4.12.18",
"is-deflate": "^1.0.0",
@@ -2943,6 +2944,33 @@
"node": ">=12.20.0"
}
},
+ "node_modules/@tanstack/react-virtual": {
+ "version": "3.13.26",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.26.tgz",
+ "integrity": "sha512-DosdgjOxCLahkn0o+ilmZYwEjo1glfMGuRT/j3PQ18yr5XqA8N/BCaL9IJ3B5TRl+nnzyK2IOFgAILwzN3a9xQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/virtual-core": "3.16.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.16.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.16.0.tgz",
+ "integrity": "sha512-Er2N7q3WOiH6y2JLxsxNX+u2/sLqSsL0bxFgDjuiPiA7vKhZRm+IzcS17vRee3GNXr64UsesA5CAp9yTiIYw9A==",
+ "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",
@@ -9723,6 +9751,19 @@
"apg-lite": "^1.0.4"
}
},
+ "@tanstack/react-virtual": {
+ "version": "3.13.26",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.26.tgz",
+ "integrity": "sha512-DosdgjOxCLahkn0o+ilmZYwEjo1glfMGuRT/j3PQ18yr5XqA8N/BCaL9IJ3B5TRl+nnzyK2IOFgAILwzN3a9xQ==",
+ "requires": {
+ "@tanstack/virtual-core": "3.16.0"
+ }
+ },
+ "@tanstack/virtual-core": {
+ "version": "3.16.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.16.0.tgz",
+ "integrity": "sha512-Er2N7q3WOiH6y2JLxsxNX+u2/sLqSsL0bxFgDjuiPiA7vKhZRm+IzcS17vRee3GNXr64UsesA5CAp9yTiIYw9A=="
+ },
"@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",
diff --git a/package.json b/package.json
index a5e3d1f..962975b 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.50.0",
+ "@tanstack/react-virtual": "^3.13.26",
"algoliasearch": "^5.51.0",
"hono": "^4.12.18",
"is-deflate": "^1.0.0",
diff --git a/src/routes/library.page.tsx b/src/routes/library.page.tsx
new file mode 100644
index 0000000..65ce543
--- /dev/null
+++ b/src/routes/library.page.tsx
@@ -0,0 +1,44 @@
+import { required } from '../utils/filter.ts';
+import Files from '../utils/jsx/islands/files.tsx';
+
+import type {
+ LibraryResponse,
+ LibraryVersionResponse,
+} from './library.schema.ts';
+
+/**
+ * /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', 'version')) {
+ 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');
+ }
+
+ return (
+
+
{library.name}
+
{library.description}
+
+
Version {version.version}
+
+
+ );
+};
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..a0f6988 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,9 @@ const handleGetLibraryVersion = async (ctx: Context) => {
withCache(ctx, 355 * 24 * 60 * 60, true);
// Send the response
- return respond(ctx, response);
+ return respond(ctx, response, ({ data }) =>
+ LibraryPage({ library: lib, version: data }),
+ );
};
/**
@@ -239,6 +243,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/islands/files.tsx b/src/utils/jsx/islands/files.tsx
new file mode 100644
index 0000000..78f4fca
--- /dev/null
+++ b/src/utils/jsx/islands/files.tsx
@@ -0,0 +1,94 @@
+import { useWindowVirtualizer } from '@tanstack/react-virtual';
+import { type CSSProperties, useLayoutEffect, useRef } from 'react';
+
+import createIsland from '../island.tsx';
+
+const File = ({
+ dir,
+ file,
+ sri,
+ style,
+}: {
+ dir: string;
+ file: string;
+ sri?: string;
+ style?: CSSProperties;
+}) => {
+ return (
+
+
+ {file}
+
+
+ {sri && (SRI: {sri})}
+
+ );
+};
+
+/**
+ * Library version files island component to render all files on the CDN for a library version.
+ *
+ * @param props Component props.
+ * @param props.dir Directory of the library version ({name}/{version}).
+ * @param props.files List of files for the library version.
+ * @param props.sri Map of file names to SRI hashes for the library version.
+ */
+const Files = ({
+ dir,
+ files,
+ sri,
+}: {
+ dir: string;
+ files: string[];
+ sri: Record;
+}) => {
+ const listRef = useRef(null);
+ const listOffsetRef = useRef(0);
+
+ useLayoutEffect(() => {
+ listOffsetRef.current = listRef.current?.offsetTop ?? 0;
+ }, []);
+
+ const virtualizer = useWindowVirtualizer({
+ count: files.length,
+ estimateSize: () => 35,
+ overscan: 5,
+ scrollMargin: listOffsetRef.current,
+ });
+
+ return (
+
+
+ {virtualizer.getVirtualItems().map((item) => {
+ const file = files[item.index];
+ if (!file) return null;
+
+ return (
+
+ );
+ })}
+
+
+ );
+};
+
+export default createIsland(Files, 'files.tsx');
diff --git a/src/utils/respond.ts b/src/utils/respond.ts
index 1392f3f..8a21314 100644
--- a/src/utils/respond.ts
+++ b/src/utils/respond.ts
@@ -72,6 +72,13 @@ export const withCache = (ctx: Context, age: number, immutable = false) => {
);
};
+/**
+ * Check if the request is asking for human-readable output (HTML) instead of the regular JSON response.
+ *
+ * @param ctx Request context.
+ */
+export const isHuman = (ctx: Context) => ctx.req.query('output') === 'human';
+
/**
* Respond to a request with data, handling if it should be returned as JSON or pretty-printed in HTML.
*
@@ -84,7 +91,7 @@ const respond = async (
data: NoInfer,
component: ComponentType<{ data: NoInfer }> = Json,
) => {
- if (ctx.req.query('output') === 'human') {
+ if (isHuman(ctx)) {
event('human-output', { ctx });
ctx.header('X-Robots-Tag', 'noindex');