diff --git a/server.ts b/server.ts index e351769..ef70d11 100644 --- a/server.ts +++ b/server.ts @@ -253,7 +253,17 @@ if (isProd) { await runMigrations() const template = readFileSync(clientHtml, 'utf-8') - const { render } = await import(ssrBundle as string) + const { render, loadData } = await import(ssrBundle as string) + + app.get('/__data', async (c) => { + const path = new URL(c.req.url).searchParams.get('path') + if (!path) return c.json({ error: 'Missing path param' }, 400) + const fakeUrl = new URL(path, c.req.url) + const req = new Request(fakeUrl, { headers: c.req.raw.headers }) + const result = await loadData(req) + if (result.redirect) return c.json({ redirect: result.redirect }, 200) + return c.json(result) + }) app.get('*', async (c) => { const { html, ssrData, redirect, statusCode, title, description } = await render(c.req.raw) @@ -299,6 +309,17 @@ if (isProd) { }) }) + app.get('/__data', async (c) => { + const path = new URL(c.req.url).searchParams.get('path') + if (!path) return c.json({ error: 'Missing path param' }, 400) + const { loadData } = await vite!.ssrLoadModule('/src/entry-server.tsx') + const fakeUrl = new URL(path, c.req.url) + const req = new Request(fakeUrl, { headers: c.req.raw.headers }) + const result = await loadData(req) + if (result.redirect) return c.json({ redirect: result.redirect }, 200) + return c.json(result) + }) + app.get('*', async (c) => { const url = c.req.url let template = readFileSync(resolve('index.html'), 'utf-8') diff --git a/src/entry-server.tsx b/src/entry-server.tsx index 00f1654..8d8aa4b 100644 --- a/src/entry-server.tsx +++ b/src/entry-server.tsx @@ -6,38 +6,18 @@ import { StaticRouter } from 'react-router' import App, { routes } from '~/App' import { SSRDataProvider } from '~/lib/ssr-data' import { runLoaders } from '~/loaders.server' +import { matchRoutes } from '~/route-gen' -function matchPath(pattern: string, pathname: string): Record | null { - const patternParts = pattern.split('/').filter(Boolean) - const pathParts = pathname.split('/').filter(Boolean) - - if (patternParts.length !== pathParts.length) return null - - const params: Record = {} - for (let i = 0; i < patternParts.length; i++) { - const pat = patternParts[i]! - const val = pathParts[i]! - if (pat.startsWith(':')) { - params[pat.slice(1)] = val - } else if (pat !== val) { - return null - } - } - return params +type LoaderResult = { + data: Record + redirect?: string + statusCode?: number + title?: string + description?: string } -function matchRoutes(url: string) { - const pathname = new URL(url, 'http://localhost').pathname - const matched: { path: string; params: Record }[] = [] - - for (const route of routes) { - const params = matchPath(route.path, pathname) - if (params !== null) { - matched.push({ path: route.path, params }) - break // first match wins - } - } - return matched +export async function loadData(request: Request): Promise { + return runLoaders(matchRoutes(routes, request.url), request) } export async function render(request: Request): Promise<{ @@ -48,31 +28,20 @@ export async function render(request: Request): Promise<{ title?: string description?: string }> { - const url = request.url - const pathname = new URL(url, 'http://localhost').pathname - const matchedRoutes = matchRoutes(url) - - let ssrData: Record - let redirect: string | undefined - let statusCode: number | undefined - let title: string | undefined - let description: string | undefined + const pathname = new URL(request.url, 'http://localhost').pathname - try { - const result = await runLoaders(matchedRoutes, request) - ssrData = result.data - redirect = result.redirect - statusCode = result.statusCode - title = result.title - description = result.description - } catch (err) { + const result = await loadData(request).catch((err: unknown) => { console.error('Loader error:', err) - ssrData = {} - statusCode = 500 - } + return { data: {}, statusCode: 500 } as LoaderResult + }) - if (redirect) { - return { html: '', ssrData: {}, redirect, statusCode: statusCode ?? 302 } + if (result.redirect) { + return { + html: '', + ssrData: {}, + redirect: result.redirect, + statusCode: result.statusCode ?? 302, + } } return new Promise((resolve, reject) => { @@ -84,7 +53,7 @@ export async function render(request: Request): Promise<{ const { pipe } = renderToPipeableStream( - + , @@ -94,10 +63,10 @@ export async function render(request: Request): Promise<{ passthrough.on('end', () => resolve({ html, - ssrData, - ...(statusCode !== undefined && { statusCode }), - ...(title !== undefined && { title }), - ...(description !== undefined && { description }), + ssrData: result.data, + ...(result.statusCode !== undefined && { statusCode: result.statusCode }), + ...(result.title !== undefined && { title: result.title }), + ...(result.description !== undefined && { description: result.description }), }), ) }, diff --git a/src/lib/collections.server.ts b/src/lib/collections.server.ts new file mode 100644 index 0000000..4de9e2c --- /dev/null +++ b/src/lib/collections.server.ts @@ -0,0 +1,109 @@ +import { and, eq, sql } from 'drizzle-orm' + +import { db, schema } from '~/db/client.server' +import { buildArkUrl, DEFAULT_NAAN } from '~/lib/ark' + +export async function getCollectionPageData(owner: string, slug: string, userId?: string) { + const [result] = await db + .select({ + id: schema.collections.id, + slug: schema.collections.slug, + name: schema.collections.name, + description: schema.collections.description, + public: schema.collections.public, + ownerSlug: schema.organization.slug, + ownerName: schema.organization.name, + createdAt: schema.collections.createdAt, + updatedAt: schema.collections.updatedAt, + }) + .from(schema.collections) + .innerJoin(schema.organization, eq(schema.collections.organizationId, schema.organization.id)) + .where(and(eq(schema.organization.slug, owner), eq(schema.collections.slug, slug))) + .limit(1) + + if (!result) return null + + if (!result.public) { + const [org] = await db + .select({ id: schema.organization.id }) + .from(schema.organization) + .where(eq(schema.organization.slug, owner)) + .limit(1) + + if (!org) return null + + let hasAccess = false + if (userId) { + const [membership] = await db + .select() + .from(schema.member) + .where(and(eq(schema.member.organizationId, org.id), eq(schema.member.userId, userId))) + .limit(1) + hasAccess = !!membership + } + if (!hasAccess) return null + } + + const [latestVersion] = await db + .select({ + id: schema.versions.id, + number: schema.versions.number, + semver: schema.versions.semver, + recordCount: schema.versions.recordCount, + fileCount: schema.versions.fileCount, + totalBytes: schema.versions.totalBytes, + createdAt: schema.versions.createdAt, + message: schema.versions.message, + readme: schema.versions.readme, + }) + .from(schema.versions) + .where(eq(schema.versions.collectionId, result.id)) + .orderBy(sql`${schema.versions.number} desc`) + .limit(1) + + let typeCounts: { type: string; count: number }[] = [] + if (latestVersion) { + const rows = await db + .select({ + type: schema.records.type, + count: sql`count(*)::int`, + }) + .from(schema.records) + .where(eq(schema.records.versionId, latestVersion.id)) + .groupBy(schema.records.type) + typeCounts = rows.map((r) => ({ type: r.type, count: r.count })) + } + + let ark: string | null = null + try { + const [arkRow] = await db + .select({ + arkId: schema.arkCollections.arkId, + enabled: schema.arkCollections.enabled, + shoulder: schema.arkShoulders.shoulder, + ownerNaan: schema.organization.arkNaan, + }) + .from(schema.arkCollections) + .innerJoin(schema.collections, eq(schema.arkCollections.collectionId, schema.collections.id)) + .innerJoin(schema.organization, eq(schema.collections.organizationId, schema.organization.id)) + .innerJoin( + schema.arkShoulders, + eq(schema.arkShoulders.organizationId, schema.organization.id), + ) + .where(eq(schema.arkCollections.collectionId, result.id)) + .limit(1) + if (arkRow?.enabled) { + ark = buildArkUrl(arkRow.ownerNaan ?? DEFAULT_NAAN, arkRow.shoulder, arkRow.arkId) + } + } catch { + // Non-fatal + } + + const { id: _id, ...collectionData } = result + const { id: _vid, ...latestVersionData } = latestVersion ?? { id: undefined } + return { + ...collectionData, + ark, + latestVersion: latestVersion ? { ...latestVersionData, typeCounts } : null, + } +} diff --git a/src/lib/ssr-data.tsx b/src/lib/ssr-data.tsx index e337bff..2f26539 100644 --- a/src/lib/ssr-data.tsx +++ b/src/lib/ssr-data.tsx @@ -1,16 +1,69 @@ -import { createContext, useContext } from 'react' +import { createContext, useContext, useEffect, useRef, useState } from 'react' +import { useLocation } from 'react-router' type SSRData = Record const SSRDataContext = createContext({}) +const SSRNavigatingContext = createContext(false) export function SSRDataProvider({ data, children }: { data: SSRData; children: React.ReactNode }) { - return {children} + const location = useLocation() + const [currentData, setCurrentData] = useState(data) + const [dataPath, setDataPath] = useState(location.pathname) + const isInitial = useRef(true) + const abortRef = useRef(null) + + // Computed synchronously during render — true when the route changed but data hasn't arrived + const navigating = !isInitial.current && location.pathname !== dataPath + + useEffect(() => { + if (isInitial.current) { + isInitial.current = false + return + } + if (location.pathname === dataPath) return + + abortRef.current?.abort() + const controller = new AbortController() + abortRef.current = controller + + fetch(`/__data?path=${encodeURIComponent(location.pathname)}`, { + credentials: 'include', + signal: controller.signal, + }) + .then((r) => r.json()) + .then((result) => { + if (controller.signal.aborted) return + if (result.redirect) { + window.location.href = result.redirect + return + } + setCurrentData(result.data) + setDataPath(location.pathname) + if (result.title) document.title = result.title + }) + .catch((err) => { + if (err instanceof DOMException && err.name === 'AbortError') return + console.error('Failed to load route data:', err) + setDataPath(location.pathname) + }) + + return () => controller.abort() + }, [location.pathname, dataPath]) + + return ( + + {children} + + ) } export function useSSRData(key: string): T { - const data = useContext(SSRDataContext) - return data[key] as T + return useContext(SSRDataContext)[key] as T +} + +export function useSSRNavigating(): boolean { + return useContext(SSRNavigatingContext) } export function getClientSSRData(): SSRData { diff --git a/src/loaders.server.ts b/src/loaders.server.ts index f4cdf7c..75f52e1 100644 --- a/src/loaders.server.ts +++ b/src/loaders.server.ts @@ -1,4 +1,5 @@ import { getSessionUser } from '~/lib/auth.server' +import { getCollectionPageData } from '~/lib/collections.server' import { getMirrorConfig } from '~/lib/mirror-config' type LoaderContext = { @@ -18,17 +19,28 @@ type LoaderFn = (ctx: LoaderContext) => LoaderResult | Promise const mirrorConfig = getMirrorConfig() -// Shared helper: require auth or redirect to login async function requireUser(request: Request): Promise<{ user: any; redirect?: string }> { const user = await getSessionUser(request) - if (!user) { - return { user: null, redirect: '/login' } - } + if (!user) return { user: null, redirect: '/login' } return { user } } +function page(title: string, extra?: Record): LoaderFn { + return async ({ request }) => { + const user = await getSessionUser(request) + return { data: { currentUser: user, mirrorConfig, ...extra }, title } + } +} + +function authPage(title: string, extra?: Record): LoaderFn { + return async ({ request }) => { + const { user, redirect } = await requireUser(request) + if (redirect) return { data: {}, redirect } + return { data: { currentUser: user, mirrorConfig, ...extra }, title } + } +} + const loaders: Record = { - // --- Public pages --- '/': async ({ request }) => { const user = await getSessionUser(request) return { @@ -39,245 +51,61 @@ const loaders: Record = { } }, - '/explore': async ({ request }) => { - const user = await getSessionUser(request) - return { - data: { currentUser: user, mirrorConfig }, - title: 'Explore — Underlay', - } - }, - - '/query': async ({ request }) => { - const user = await getSessionUser(request) - return { - data: { currentUser: user, mirrorConfig }, - title: 'Query Explorer — Underlay', - } - }, + '/explore': page('Explore — Underlay'), + '/query': page('Query Explorer — Underlay'), + '/schemas': page('Schemas — Underlay'), + '/schemas/:id': page('Schema — Underlay'), + '/blog': page('Blog — Underlay'), + '/blog/:slug': page('Blog — Underlay'), + '/docs': page('Documentation — Underlay'), + '/docs/concepts': page('Core Concepts — Underlay Docs'), + '/docs/quickstart': page('Quickstart — Underlay Docs'), + '/docs/integration': page('Integration — Underlay Docs'), + '/docs/self-host': page('Self-hosting — Underlay Docs'), + '/docs/api': page('API Reference — Underlay Docs'), + '/docs/api/accounts': page('Accounts API — Underlay Docs'), + '/docs/api/collections': page('Collections API — Underlay Docs'), + '/docs/api/versions': page('Versions API — Underlay Docs'), + '/docs/api/files': page('Files API — Underlay Docs'), + '/admin/mirror': page('Mirror Admin — Underlay'), - '/schemas': async ({ request }) => { - const user = await getSessionUser(request) - return { - data: { currentUser: user, mirrorConfig }, - title: 'Schemas — Underlay', - } - }, - - '/schemas/:id': async ({ params, request }) => { - const user = await getSessionUser(request) - return { - data: { currentUser: user, mirrorConfig, schemaId: params['id'] }, - title: 'Schema — Underlay', - } - }, - - // --- Auth pages --- '/login': async ({ request }) => { const user = await getSessionUser(request) if (user) return { data: {}, redirect: '/dashboard' } - return { - data: { currentUser: null, mirrorConfig }, - title: 'Log in — Underlay', - } + return { data: { currentUser: null, mirrorConfig }, title: 'Log in — Underlay' } }, '/signup': async ({ request }) => { const user = await getSessionUser(request) if (user) return { data: {}, redirect: '/dashboard' } - return { - data: { currentUser: null, mirrorConfig }, - title: 'Sign up — Underlay', - } - }, - - '/logout': async () => { - return { - data: { - kfAuthUrl: process.env.OIDC_ISSUER_URL ?? 'http://localhost:3000', - }, - } - }, - - '/forgot-password': async () => { - return { - data: { currentUser: null, mirrorConfig }, - title: 'Forgot password — Underlay', - } + return { data: { currentUser: null, mirrorConfig }, title: 'Sign up — Underlay' } }, - '/reset-password': async () => { - return { - data: { currentUser: null, mirrorConfig }, - title: 'Reset password — Underlay', - } - }, + '/logout': async () => ({ + data: { kfAuthUrl: process.env.OIDC_ISSUER_URL ?? 'http://localhost:3000' }, + }), - // --- Dashboard/Settings --- - '/dashboard': async ({ request }) => { - const { user, redirect } = await requireUser(request) - if (redirect) return { data: {}, redirect } - return { - data: { currentUser: user, mirrorConfig }, - title: 'Dashboard — Underlay', - } - }, + '/forgot-password': async () => ({ + data: { currentUser: null, mirrorConfig }, + title: 'Forgot password — Underlay', + }), - '/settings': async ({ request }) => { - const { user, redirect } = await requireUser(request) - if (redirect) return { data: {}, redirect } - return { - data: { currentUser: user, mirrorConfig }, - title: 'Settings — Underlay', - } - }, + '/reset-password': async () => ({ + data: { currentUser: null, mirrorConfig }, + title: 'Reset password — Underlay', + }), - '/settings/keys': async ({ request }) => { - const { user, redirect } = await requireUser(request) - if (redirect) return { data: {}, redirect } - return { - data: { currentUser: user, mirrorConfig }, - title: 'API Keys — Underlay', - } - }, + '/dashboard': authPage('Dashboard — Underlay'), + '/settings': authPage('Settings — Underlay'), + '/settings/keys': authPage('API Keys — Underlay'), + '/settings/sessions': authPage('Sessions — Underlay'), + '/settings/avatar': authPage('Avatar — Underlay'), + '/invitations/accept': authPage('Accept Invitation — Underlay'), - '/settings/sessions': async ({ request }) => { - const { user, redirect } = await requireUser(request) - if (redirect) return { data: {}, redirect } - return { - data: { currentUser: user, mirrorConfig }, - title: 'Sessions — Underlay', - } - }, - - '/settings/avatar': async ({ request }) => { - const { user, redirect } = await requireUser(request) - if (redirect) return { data: {}, redirect } - return { - data: { currentUser: user, mirrorConfig }, - title: 'Avatar — Underlay', - } - }, - - '/invitations/accept': async ({ request }) => { - const { user, redirect } = await requireUser(request) - if (redirect) return { data: {}, redirect } - return { - data: { currentUser: user, mirrorConfig }, - title: 'Accept Invitation — Underlay', - } - }, - - '/admin/mirror': async ({ request }) => { - const user = await getSessionUser(request) - return { - data: { currentUser: user, mirrorConfig }, - title: 'Mirror Admin — Underlay', - } - }, - - // --- Blog --- - '/blog': async ({ request }) => { - const user = await getSessionUser(request) - return { - data: { currentUser: user, mirrorConfig }, - title: 'Blog — Underlay', - } - }, - - '/blog/:slug': async ({ params, request }) => { - const user = await getSessionUser(request) - return { - data: { currentUser: user, mirrorConfig, slug: params['slug'] }, - title: 'Blog — Underlay', - } - }, - - // --- Docs --- - '/docs': async ({ request }) => { - const user = await getSessionUser(request) - return { - data: { currentUser: user, mirrorConfig }, - title: 'Documentation — Underlay', - } - }, - - '/docs/concepts': async ({ request }) => { - const user = await getSessionUser(request) - return { - data: { currentUser: user, mirrorConfig }, - title: 'Core Concepts — Underlay Docs', - } - }, - - '/docs/quickstart': async ({ request }) => { - const user = await getSessionUser(request) - return { - data: { currentUser: user, mirrorConfig }, - title: 'Quickstart — Underlay Docs', - } - }, - - '/docs/integration': async ({ request }) => { - const user = await getSessionUser(request) - return { - data: { currentUser: user, mirrorConfig }, - title: 'Integration — Underlay Docs', - } - }, - - '/docs/self-host': async ({ request }) => { - const user = await getSessionUser(request) - return { - data: { currentUser: user, mirrorConfig }, - title: 'Self-hosting — Underlay Docs', - } - }, - - '/docs/api': async ({ request }) => { - const user = await getSessionUser(request) - return { - data: { currentUser: user, mirrorConfig }, - title: 'API Reference — Underlay Docs', - } - }, - - '/docs/api/accounts': async ({ request }) => { - const user = await getSessionUser(request) - return { - data: { currentUser: user, mirrorConfig }, - title: 'Accounts API — Underlay Docs', - } - }, - - '/docs/api/collections': async ({ request }) => { - const user = await getSessionUser(request) - return { - data: { currentUser: user, mirrorConfig }, - title: 'Collections API — Underlay Docs', - } - }, - - '/docs/api/versions': async ({ request }) => { - const user = await getSessionUser(request) - return { - data: { currentUser: user, mirrorConfig }, - title: 'Versions API — Underlay Docs', - } - }, - - '/docs/api/files': async ({ request }) => { - const user = await getSessionUser(request) - return { - data: { currentUser: user, mirrorConfig }, - title: 'Files API — Underlay Docs', - } - }, - - // --- Dynamic owner/collection --- '/:owner': async ({ params, request }) => { const user = await getSessionUser(request) return { - data: { currentUser: user, mirrorConfig, owner: params['owner'] }, + data: { currentUser: user, mirrorConfig }, title: `${params['owner']} — Underlay`, } }, @@ -286,7 +114,7 @@ const loaders: Record = { const { user, redirect } = await requireUser(request) if (redirect) return { data: {}, redirect } return { - data: { currentUser: user, mirrorConfig, owner: params['owner'] }, + data: { currentUser: user, mirrorConfig }, title: `Settings — ${params['owner']} — Underlay`, } }, @@ -295,7 +123,7 @@ const loaders: Record = { const { user, redirect } = await requireUser(request) if (redirect) return { data: {}, redirect } return { - data: { currentUser: user, mirrorConfig, owner: params['owner'] }, + data: { currentUser: user, mirrorConfig }, title: `API Keys — ${params['owner']} — Underlay`, } }, @@ -304,21 +132,26 @@ const loaders: Record = { const { user, redirect } = await requireUser(request) if (redirect) return { data: {}, redirect } return { - data: { currentUser: user, mirrorConfig, owner: params['owner'] }, + data: { currentUser: user, mirrorConfig }, title: `Members — ${params['owner']} — Underlay`, } }, '/:owner/:collection': async ({ params, request }) => { const user = await getSessionUser(request) + const collection = await getCollectionPageData( + params['owner']!, + params['collection']!, + user?.id, + ) return { data: { currentUser: user, mirrorConfig, - owner: params['owner'], - collection: params['collection'], + collection, }, title: `${params['owner']}/${params['collection']} — Underlay`, + ...(collection ? {} : { statusCode: 404 }), } }, diff --git a/src/route-gen.ts b/src/route-gen.ts index 0cb07f1..d6e932f 100644 --- a/src/route-gen.ts +++ b/src/route-gen.ts @@ -79,3 +79,32 @@ export function buildRoutes(globResult: Record Promise>): return sortRoutes(entries) } + +export function matchRoutes( + routes: RouteEntry[], + url: string, +): { path: string; params: Record }[] { + const pathname = new URL(url, 'http://localhost').pathname + + for (const route of routes) { + const patternParts = route.path.split('/').filter(Boolean) + const pathParts = pathname.split('/').filter(Boolean) + + if (patternParts.length !== pathParts.length) continue + + const params: Record = {} + let matched = true + for (let i = 0; i < patternParts.length; i++) { + const pat = patternParts[i]! + const val = pathParts[i]! + if (pat.startsWith(':')) { + params[pat.slice(1)] = val + } else if (pat !== val) { + matched = false + break + } + } + if (matched) return [{ path: route.path, params }] + } + return [] +} diff --git a/src/routes/[owner]/[collection]/index.tsx b/src/routes/[owner]/[collection]/index.tsx index b3aa337..58c9fb2 100644 --- a/src/routes/[owner]/[collection]/index.tsx +++ b/src/routes/[owner]/[collection]/index.tsx @@ -1,9 +1,9 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Link, useParams } from 'react-router' import BaseLayout from '~/components/BaseLayout' import { NotFoundError } from '~/components/NotFound' -import { useSSRData } from '~/lib/ssr-data' +import { useSSRData, useSSRNavigating } from '~/lib/ssr-data' function CollectionNav({ owner, @@ -87,54 +87,59 @@ export default function CollectionPage() { const { owner, collection } = useParams() const currentUser = useSSRData('currentUser') const mirrorConfig = useSSRData('mirrorConfig') - - const [data, setData] = useState(null) - const [totalVersions, setTotalVersions] = useState(0) - const [isOwner, setIsOwner] = useState(false) + const data = useSSRData('collection') + const navigating = useSSRNavigating() const [readmeHtml, setReadmeHtml] = useState(null) - const [loading, setLoading] = useState(true) - - useEffect(() => { - if (!owner || !collection) return - - fetch(`/api/collections/${owner}/${collection}`, { credentials: 'include' }) - .then((r) => (r.ok ? r.json() : null)) - .then((col) => { - if (!col) { - setLoading(false) - return - } - setData(col) - setTotalVersions(col.latestVersion?.number ?? 0) - // Render readme - const readmeSource = col.latestVersion?.readme || col.latestVersion?.message || null - if (readmeSource) { - import('marked').then(({ marked }) => { - setReadmeHtml(marked.parse(readmeSource) as string) - }) - } - - // Check ownership - if (currentUser) { - setIsOwner( - currentUser.slug === owner || currentUser.orgs?.some((o: any) => o.slug === owner), - ) - } + const isOwner = useMemo( + () => + !!currentUser && + (currentUser.slug === owner || currentUser.orgs?.some((o: any) => o.slug === owner)), + [currentUser, owner], + ) - setLoading(false) + useEffect(() => { + const readmeSource = data?.latestVersion?.readme || data?.latestVersion?.message || null + if (readmeSource) { + import('marked').then(({ marked }) => { + setReadmeHtml(marked.parse(readmeSource) as string) }) - }, [owner, collection, currentUser]) + } else { + setReadmeHtml(null) + } + }, [data]) - if (loading) { + // Client navigation: data hasn't arrived yet — show skeleton + if (navigating && !data) { return ( -
Loading…
+
+
+ {owner} + / + {collection} +
+
+
+
+
+
+
+
+
+
+
+
+
+
) } + + // SSR or data arrived: no collection found if (!data) throw new NotFoundError() + const totalVersions = data.latestVersion?.number ?? 0 const typeCounts: { type: string; count: number }[] = data.latestVersion?.typeCounts ?? [] const allTypes = typeCounts.sort((a: any, b: any) => a.type.localeCompare(b.type)) const collectionArkPath: string | null = data.ark ? new URL(data.ark).pathname : null