From c13f076a6ee10e823eecee09db33f1390915b051 Mon Sep 17 00:00:00 2001 From: MattIPv4 Date: Sun, 31 May 2026 20:39:23 +0100 Subject: [PATCH 1/7] Redirect library human output to latest version --- src/routes/library.spec.ts | 9 +++++++-- src/routes/library.ts | 11 ++++++++++- src/utils/respond.ts | 9 ++++++++- 3 files changed, 25 insertions(+), 4 deletions(-) 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..e2dda97 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,7 +12,7 @@ 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 { @@ -239,6 +240,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/respond.ts b/src/utils/respond.ts index 1da9500..1ff501f 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'); From d8e4a032415333c55ed2ce73f20969f93b24f4d9 Mon Sep 17 00:00:00 2001 From: MattIPv4 Date: Sun, 31 May 2026 20:38:59 +0100 Subject: [PATCH 2/7] Initial virtualized file list --- package-lock.json | 41 +++++++++++++ package.json | 1 + src/routes/library.page.tsx | 45 ++++++++++++++ src/routes/library.ts | 5 +- src/utils/filter.ts | 19 ++++++ src/utils/jsx/islands/files.tsx | 100 ++++++++++++++++++++++++++++++++ 6 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 src/routes/library.page.tsx create mode 100644 src/utils/jsx/islands/files.tsx diff --git a/package-lock.json b/package-lock.json index 68e57e5..269b968 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", @@ -2868,6 +2869,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", @@ -9576,6 +9604,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", diff --git a/package.json b/package.json index 0a9379e..3dc3151 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", diff --git a/src/routes/library.page.tsx b/src/routes/library.page.tsx new file mode 100644 index 0000000..b0ea629 --- /dev/null +++ b/src/routes/library.page.tsx @@ -0,0 +1,45 @@ +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.ts b/src/routes/library.ts index e2dda97..a0f6988 100644 --- a/src/routes/library.ts +++ b/src/routes/library.ts @@ -15,6 +15,7 @@ import { queryCheck } from '../utils/query.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, @@ -130,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 }), + ); }; /** 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..00e7a50 --- /dev/null +++ b/src/utils/jsx/islands/files.tsx @@ -0,0 +1,100 @@ +import { useWindowVirtualizer } from '@tanstack/react-virtual'; +import { type CSSProperties, useLayoutEffect, useRef } from 'react'; + +import createIsland from '../island.tsx'; + +const File = ({ + name, + version, + file, + sri, + style, +}: { + name: string; + version: 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.name Library name. + * @param props.version Library 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 = ({ + name, + version, + files, + sri, +}: { + name: string; + version: 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'); From 711b48b20a4ff1fad0401ac8921a2fb9857c3007 Mon Sep 17 00:00:00 2001 From: MattIPv4 Date: Sun, 7 Jun 2026 16:58:01 +0100 Subject: [PATCH 3/7] Build library header --- package-lock.json | 30 +++++-- package.json | 2 + src/routes/library.page.tsx | 168 +++++++++++++++++++++++++++++++++++- src/utils/theme.ts | 5 ++ 4 files changed, 193 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index 269b968..a17a30f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,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" }, @@ -35,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", @@ -3069,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", @@ -6881,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", @@ -9752,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", @@ -12151,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 3dc3151..bc2270d 100644 --- a/package.json +++ b/package.json @@ -43,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" }, @@ -57,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 index b0ea629..2b27c88 100644 --- a/src/routes/library.page.tsx +++ b/src/routes/library.page.tsx @@ -1,11 +1,98 @@ +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. * @@ -28,10 +115,83 @@ export default ({ throw new Error('Library version data is missing required fields'); } + const repo = libraryRepo(library); + return ( -
    -

    {library.name}

    -

    {library.description}

    + <> +
    +
    +

    {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 && ', '} + + ))} +

    + )} +

    Version {version.version}

    -
    + ); }; diff --git a/src/utils/theme.ts b/src/utils/theme.ts index 6a7a9e2..2fe7f03 100644 --- a/src/utils/theme.ts +++ b/src/utils/theme.ts @@ -11,6 +11,7 @@ export default { background: { body: '#454647', navigation: '#343535', + header: '#3a3c3c', footer: '#242525', brand: '#d9643a', }, @@ -26,6 +27,10 @@ export default { medium: breakpoint(96), }, font: { + heading: { + size: '3rem', + weight: 600, + }, large: { size: '1.25rem', weight: 400, From 4c0c76ef93ce73fa8e5b1d63077508d491abe8ba Mon Sep 17 00:00:00 2001 From: MattIPv4 Date: Sun, 7 Jun 2026 17:49:43 +0100 Subject: [PATCH 4/7] Add version dropdown --- src/routes/library.page.tsx | 4 +- src/routes/library.ts | 7 +- src/utils/jsx/islands/files.tsx | 154 ++++++++++++++++++++++++++------ 3 files changed, 132 insertions(+), 33 deletions(-) diff --git a/src/routes/library.page.tsx b/src/routes/library.page.tsx index 2b27c88..8923ec9 100644 --- a/src/routes/library.page.tsx +++ b/src/routes/library.page.tsx @@ -107,7 +107,7 @@ export default ({ library: LibraryResponse; version: LibraryVersionResponse; }) => { - if (!required(library, 'name', 'description', 'version')) { + if (!required(library, 'name', 'description', 'versions')) { throw new Error('Library data is missing required fields'); } @@ -193,12 +193,12 @@ export default ({ )} -

    Version {version.version}

    ); diff --git a/src/routes/library.ts b/src/routes/library.ts index a0f6988..7ce67b8 100644 --- a/src/routes/library.ts +++ b/src/routes/library.ts @@ -131,9 +131,10 @@ const handleGetLibraryVersion = async (ctx: Context) => { withCache(ctx, 355 * 24 * 60 * 60, true); // Send the response - return respond(ctx, response, ({ data }) => - LibraryPage({ library: lib, version: data }), - ); + return respond(ctx, response, async ({ data }) => { + const versions = await libraryVersions(lib.name); + return LibraryPage({ library: { ...lib, versions }, version: data }); + }); }; /** diff --git a/src/utils/jsx/islands/files.tsx b/src/utils/jsx/islands/files.tsx index 00e7a50..61489c5 100644 --- a/src/utils/jsx/islands/files.tsx +++ b/src/utils/jsx/islands/files.tsx @@ -1,8 +1,95 @@ +import { css } from '@emotion/css'; import { useWindowVirtualizer } from '@tanstack/react-virtual'; -import { type CSSProperties, useLayoutEffect, useRef } from 'react'; +import { type CSSProperties, useLayoutEffect, useRef, useState } from 'react'; +import theme from '../../theme.ts'; 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}; + } + } + `, +}; + +const Versions = ({ + name, + version, + versions, +}: { + name: string; + version: string; + versions: string[]; +}) => { + const [selected, setSelected] = useState(version); + return ( +
    + + +
    + ); +}; + const File = ({ name, version, @@ -39,17 +126,20 @@ const File = ({ * @param props.version Library 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. + * @param props.versions List of all versions for the library. */ const Files = ({ name, version, files, sri, + versions, }: { name: string; version: string; files: string[]; sri: Record; + versions: string[]; }) => { const listRef = useRef(null); const listOffsetRef = useRef(0); @@ -66,34 +156,42 @@ const Files = ({ }); return ( -
      - {virtualizer.getVirtualItems().map((item) => { - const file = files[item.index]; - if (!file) return null; + <> +
      + + {`https://cdnjs.cloudflare.com/ajax/libs/${name}/${version}/...`} + + +
      +
        + {virtualizer.getVirtualItems().map((item) => { + const file = files[item.index]; + if (!file) return null; - return ( - - ); - })} -
      + return ( + + ); + })} +
    + ); }; From 45a9671b7fadda655c936089b56cfa81e913a12c Mon Sep 17 00:00:00 2001 From: MattIPv4 Date: Sun, 7 Jun 2026 18:20:20 +0100 Subject: [PATCH 5/7] Add filter dropdown --- src/utils/jsx/islands/files.tsx | 76 +++++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 3 deletions(-) diff --git a/src/utils/jsx/islands/files.tsx b/src/utils/jsx/islands/files.tsx index 61489c5..bd87c71 100644 --- a/src/utils/jsx/islands/files.tsx +++ b/src/utils/jsx/islands/files.tsx @@ -1,7 +1,15 @@ import { css } from '@emotion/css'; import { useWindowVirtualizer } from '@tanstack/react-virtual'; -import { type CSSProperties, useLayoutEffect, useRef, useState } from 'react'; +import { + type CSSProperties, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import fileTypes from '../../files.ts'; import theme from '../../theme.ts'; import createIsland from '../island.tsx'; @@ -90,6 +98,66 @@ const Versions = ({ ); }; +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]); + + useEffect(() => { + if (!types.has(selected) || types.size <= 1) { + setSelected(''); + } + }, [types, selected]); + + useEffect(() => { + onChange( + selected === '' + ? mapped.map((x) => x.file) + : mapped.filter((x) => x.type === selected).map((x) => x.file), + ); + }, [selected, mapped, onChange]); + + if (types.size <= 1) return null; + + return ( +
    + + +
    + ); +}; + const File = ({ name, version, @@ -141,6 +209,7 @@ const Files = ({ sri: Record; versions: string[]; }) => { + const [listFiles, setListFiles] = useState(files); const listRef = useRef(null); const listOffsetRef = useRef(0); @@ -149,7 +218,7 @@ const Files = ({ }, []); const virtualizer = useWindowVirtualizer({ - count: files.length, + count: listFiles.length, estimateSize: () => 35, overscan: 5, scrollMargin: listOffsetRef.current, @@ -162,6 +231,7 @@ const Files = ({ {`https://cdnjs.cloudflare.com/ajax/libs/${name}/${version}/...`} +