Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
44 changes: 44 additions & 0 deletions src/routes/library.page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<h1>{library.name}</h1>
<p>{library.description}</p>

<h2>Version {version.version}</h2>
<Files
dir={`${library.name}/${version.version}`}
files={version.files}
sri={version.sri}
/>
</div>
);
};
9 changes: 7 additions & 2 deletions src/routes/library.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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)', () => {
Expand Down
16 changes: 14 additions & 2 deletions src/routes/library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -129,7 +131,9 @@ const handleGetLibraryVersion = async (ctx: Context) => {
withCache(ctx, 355 * 24 * 60 * 60, true);

// Send the response
return respond<LibraryVersionResponse>(ctx, response);
return respond<LibraryVersionResponse>(ctx, response, ({ data }) =>
LibraryPage({ library: lib, version: data }),
);
};

/**
Expand Down Expand Up @@ -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<LibraryResponse>(ctx, response);
};
Expand Down
19 changes: 19 additions & 0 deletions src/utils/filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,22 @@ export default <T extends Record<string, unknown>>(
Object.fromEntries(
Object.entries(source).filter(([key]) => filter(key)),
) as Partial<T>;

/**
* 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<keyof T, string>[],
>(
source: T,
...keys: K
): source is T & Required<Pick<T, K[number]>> => {
for (const key of keys) {
if (source[key] === undefined) return false;
}
return true;
};
94 changes: 94 additions & 0 deletions src/utils/jsx/islands/files.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<li style={style}>
<a href={`https://cdnjs.cloudflare.com/ajax/libs/${dir}/${file}`}>
{file}
</a>

{sri && <code style={{ marginLeft: '0.5em' }}>(SRI: {sri})</code>}
</li>
);
};

/**
* 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<string, string>;
}) => {
const listRef = useRef<HTMLUListElement | null>(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 (
<ul ref={listRef}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((item) => {
const file = files[item.index];
if (!file) return null;

return (
<File
key={file}
dir={dir}
file={file}
sri={sri[file]}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${item.size}px`,
transform: `translateY(${item.start - virtualizer.options.scrollMargin}px)`,
}}
/>
);
})}
</div>
</ul>
);
};

export default createIsland(Files, 'files.tsx');
9 changes: 8 additions & 1 deletion src/utils/respond.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -84,7 +91,7 @@ const respond = async <T = never>(
data: NoInfer<T>,
component: ComponentType<{ data: NoInfer<T> }> = Json,
) => {
if (ctx.req.query('output') === 'human') {
if (isHuman(ctx)) {
event('human-output', { ctx });
ctx.header('X-Robots-Tag', 'noindex');

Expand Down