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');