diff --git a/src/__tests__/components/ReadmeSection.test.tsx b/src/__tests__/components/ReadmeSection.test.tsx new file mode 100644 index 0000000..97d39cd --- /dev/null +++ b/src/__tests__/components/ReadmeSection.test.tsx @@ -0,0 +1,58 @@ +import { render, screen, within } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { ReadmeSection } from '@/components/ReadmeSection'; + +const baseProps = { + ariaLabel: 'Bundle README', + emptyMessage: 'No README is available for this bundle.', + isError: false, + isLoading: false, + testId: 'bundle-detail-readme', +}; + +describe('ReadmeSection', () => { + it('renders a labelled section with a README heading', () => { + render(); + + const section = screen.getByTestId('bundle-detail-readme'); + expect(section).toHaveAttribute('aria-label', 'Bundle README'); + expect(within(section).getByRole('heading', { level: 2, name: 'README' })).toBeInTheDocument(); + }); + + it('renders the markdown when a README is present', () => { + render(); + + const section = screen.getByTestId('bundle-detail-readme'); + expect(within(section).getByTestId('markdown-renderer')).toBeInTheDocument(); + expect(within(section).getByRole('heading', { level: 1, name: 'Hello' })).toBeInTheDocument(); + expect(within(section).getByText('item one')).toBeInTheDocument(); + expect(screen.queryByTestId('bundle-detail-readme-missing')).not.toBeInTheDocument(); + }); + + it('shows a loading skeleton while the README is inflight', () => { + render(); + + expect(screen.getByRole('status', { name: /loading readme/i })).toBeInTheDocument(); + }); + + it('shows the empty message when the README is missing', () => { + render(); + + expect(screen.getByTestId('bundle-detail-readme-missing')).toHaveTextContent( + 'No README is available for this bundle.', + ); + }); + + it('treats an empty string as a missing README', () => { + render(); + + expect(screen.getByTestId('bundle-detail-readme-missing')).toBeInTheDocument(); + }); + + it('falls back to the empty message when the fetch failed', () => { + render(); + + expect(screen.getByTestId('bundle-detail-readme-missing')).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/lib/query-hooks.test.tsx b/src/__tests__/lib/query-hooks.test.tsx index 712c65d..1590194 100644 --- a/src/__tests__/lib/query-hooks.test.tsx +++ b/src/__tests__/lib/query-hooks.test.tsx @@ -7,11 +7,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ApiClient } from '@/lib/api-client'; import { useAssetFiles } from '@/hooks/useAssetFiles'; +import { useBundleReadme } from '@/hooks/useBundleReadme'; import { useRegistry } from '@/hooks/useRegistry'; import { queryKeys } from '@/lib/query-keys'; import { loadFixtureRegistry } from '../fixtures'; -import { apiErrorResponse, jsonResponse, makeTestApiClient, stubFetch } from '../utils/api-stub'; +import { apiErrorResponse, jsonResponse, makeTestApiClient, stubFetch, textResponse } from '../utils/api-stub'; // Intercept the session so we can feed the hooks a controlled API client (or null). const sessionValueMock: { @@ -149,3 +150,54 @@ describe('useAssetFiles (TanStack Query)', () => { expect(client.getQueryData(queryKeys.assetFiles(ref))).toBeDefined(); }); }); + +describe('useBundleReadme (TanStack Query)', () => { + beforeEach(() => { + sessionValueMock.api = null; + sessionValueMock.token = null; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('stays disabled until the registry has resolved a version', async () => { + sessionValueMock.api = makeTestApiClient('tok'); + const { calls } = stubFetch(() => textResponse('# Hi')); + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useBundleReadme({ name: 'qa-bundle', org: 'cupay' }), { wrapper: Wrapper }); + + await waitFor(() => expect(result.current.fetchStatus).toBe('idle')); + expect(calls).toHaveLength(0); + }); + + it('fetches the README markdown under the bundleReadme key', async () => { + sessionValueMock.token = 'tok'; + sessionValueMock.api = makeTestApiClient('tok'); + const { calls } = stubFetch(() => textResponse('# Hi')); + + const { client, Wrapper } = makeWrapper(); + const ref = { name: 'qa-bundle', org: 'cupay', version: '1.0.0' }; + const { result } = renderHook(() => useBundleReadme(ref), { wrapper: Wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toBe('# Hi'); + expect(calls[0]!.url).toBe('http://localhost:7071/bundles/qa-bundle/1.0.0/readme?org=cupay'); + expect(client.getQueryData(queryKeys.bundleReadme(ref))).toBe('# Hi'); + }); + + it('resolves to null when the bundle has no README', async () => { + sessionValueMock.token = 'tok'; + sessionValueMock.api = makeTestApiClient('tok'); + stubFetch(() => apiErrorResponse(404, 'not_found', 'No README')); + + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useBundleReadme({ name: 'feature-workflow', version: '1.0.0' }), { + wrapper: Wrapper, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toBeNull(); + }); +}); diff --git a/src/__tests__/lib/registry-client.test.ts b/src/__tests__/lib/registry-client.test.ts index 602ad71..8893dc7 100644 --- a/src/__tests__/lib/registry-client.test.ts +++ b/src/__tests__/lib/registry-client.test.ts @@ -5,6 +5,7 @@ import { fetchAssetManifest, fetchAssetReadme, fetchBundleManifest, + fetchBundleReadme, fetchRegistry, findExistingAsset, findExistingBundle, @@ -241,6 +242,44 @@ describe('registry-client (ATK API-backed)', () => { }); }); + describe('fetchBundleReadme', () => { + it('fetches a global bundle README as text', async () => { + const markdown = '# Quality bundle\n\nBody.'; + const { calls } = stubFetch(() => textResponse(markdown)); + + const result = await fetchBundleReadme({ name: 'quality-bundle', version: '0.3.0' }, { client }); + + expect(result).toBe(markdown); + expect(calls[0]!.url).toBe(`${API_BASE}/bundles/quality-bundle/0.3.0/readme`); + }); + + it('passes the org as a query parameter for an org bundle', async () => { + const { calls } = stubFetch(() => textResponse('# QA')); + + const result = await fetchBundleReadme({ name: 'qa-bundle', org: 'cupay', version: '1.0.0' }, { client }); + + expect(result).toBe('# QA'); + expect(calls[0]!.url).toBe(`${API_BASE}/bundles/qa-bundle/1.0.0/readme?org=cupay`); + }); + + it('returns null when the README is missing (HTTP 404)', async () => { + stubFetch(() => apiErrorResponse(404, 'not_found', 'No README')); + + const result = await fetchBundleReadme({ name: 'feature-workflow', version: '1.0.0' }, { client }); + expect(result).toBeNull(); + }); + + it('propagates transport errors as RegistryFetchError', async () => { + stubFetch(() => { + throw new TypeError('offline'); + }); + + await expect(fetchBundleReadme({ name: 'x', version: '1.0.0' }, { client })).rejects.toBeInstanceOf( + RegistryFetchError, + ); + }); + }); + describe('findExistingBundle', () => { it('returns the latest version for an existing global bundle and undefined otherwise', () => { const registry = loadFixtureRegistry(); diff --git a/src/__tests__/routes/BundleDetail.test.tsx b/src/__tests__/routes/BundleDetail.test.tsx index 762c205..f05123b 100644 --- a/src/__tests__/routes/BundleDetail.test.tsx +++ b/src/__tests__/routes/BundleDetail.test.tsx @@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { fireEvent, render, screen, within } from '@testing-library/react'; import { type ReactNode } from 'react'; import { MemoryRouter, Route, Routes } from 'react-router'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Bundle, Manifest, Registry } from '@/lib/schemas'; @@ -14,6 +14,7 @@ import { BundleDetailRoute } from '@/routes/BundleDetail'; import { loadFixtureRegistry } from '../fixtures'; const useBundleManifestMock = vi.hoisted(() => vi.fn()); +const useBundleReadmeMock = vi.hoisted(() => vi.fn()); const useRegistryMock = vi.hoisted(() => vi.fn()); const useDownloadBundleMock = vi.hoisted(() => vi.fn(() => ({ download: vi.fn().mockResolvedValue(undefined), isDownloading: () => false })), @@ -29,6 +30,7 @@ const useManifestGraphMock = vi.hoisted(() => ); vi.mock('@/hooks/useBundleManifest', () => ({ useBundleManifest: useBundleManifestMock })); +vi.mock('@/hooks/useBundleReadme', () => ({ useBundleReadme: useBundleReadmeMock })); vi.mock('@/hooks/useRegistry', () => ({ useRegistry: useRegistryMock })); vi.mock('@/hooks/useDownloadBundle', () => ({ useDownloadBundle: useDownloadBundleMock })); vi.mock('@/hooks/useManifestGraph', () => ({ @@ -38,6 +40,7 @@ vi.mock('@/hooks/useManifestGraph', () => ({ })); type BundleQueryShape = Partial>; +type ReadmeQueryShape = Partial>; type RegistryQueryShape = Partial>; function renderAt(path: string) { @@ -68,6 +71,17 @@ function setBundle(state: BundleQueryShape) { }); } +function setReadme(state: ReadmeQueryShape) { + useBundleReadmeMock.mockReturnValue({ + data: null, + error: null, + isError: false, + isLoading: false, + isSuccess: true, + ...state, + }); +} + function setRegistry(state: RegistryQueryShape) { useRegistryMock.mockReturnValue({ data: undefined, @@ -94,8 +108,14 @@ const FULL_BUNDLE: Bundle = { }; describe('BundleDetailRoute', () => { + beforeEach(() => { + // Most cases don't care about the README; default to "none" so the section renders its fallback. + setReadme({}); + }); + afterEach(() => { useBundleManifestMock.mockReset(); + useBundleReadmeMock.mockReset(); useRegistryMock.mockReset(); }); @@ -134,6 +154,86 @@ describe('BundleDetailRoute', () => { expect(within(setup).getByRole('heading', { level: 2, name: 'Setup' })).toBeInTheDocument(); }); + it('renders the bundle README markdown ahead of the setup instructions', () => { + setBundle({ data: FULL_BUNDLE, isSuccess: true }); + setRegistry({ data: loadFixtureRegistry(), isSuccess: true }); + setReadme({ + data: [ + '# Feature workflow', + '', + '## Usage', + '', + '- item one', + '- item two', + '', + '| Col A | Col B |', + '| --- | --- |', + '| a | b |', + '', + '```bash', + 'atk install feature-workflow', + '```', + '', + '[Docs](https://example.com/docs)', + ].join('\n'), + }); + + renderAt('/bundles/feature-workflow'); + + const readme = screen.getByTestId('bundle-detail-readme'); + expect(readme).toHaveAttribute('aria-label', 'Bundle README'); + expect(within(readme).getByRole('heading', { level: 2, name: 'README' })).toBeInTheDocument(); + expect(within(readme).getByRole('heading', { level: 1, name: 'Feature workflow' })).toBeInTheDocument(); + expect(within(readme).getByRole('heading', { level: 2, name: 'Usage' })).toBeInTheDocument(); + expect(within(readme).getByText('item one')).toBeInTheDocument(); + expect(within(readme).getByRole('table')).toBeInTheDocument(); + expect(within(readme).getByText('atk install feature-workflow')).toBeInTheDocument(); + expect(within(readme).getByRole('link', { name: 'Docs' })).toHaveAttribute('href', 'https://example.com/docs'); + expect(screen.queryByTestId('bundle-detail-readme-missing')).not.toBeInTheDocument(); + + // README precedes the setup instructions in document order. + const setup = screen.getByTestId('bundle-detail-setup'); + expect(readme.compareDocumentPosition(setup) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it('shows a fallback when the bundle has no README', () => { + setBundle({ data: FULL_BUNDLE, isSuccess: true }); + setRegistry({ data: loadFixtureRegistry(), isSuccess: true }); + setReadme({ data: null }); + + renderAt('/bundles/feature-workflow'); + + expect(screen.getByTestId('bundle-detail-readme-missing')).toHaveTextContent( + /no readme is available for this bundle/i, + ); + // Setup instructions still render independently of the README. + expect(screen.getByTestId('bundle-detail-setup')).toBeInTheDocument(); + }); + + it('renders a README skeleton without blocking the rest of the page', () => { + setBundle({ data: FULL_BUNDLE, isSuccess: true }); + setRegistry({ data: loadFixtureRegistry(), isSuccess: true }); + setReadme({ data: undefined, isLoading: true, isSuccess: false }); + + renderAt('/bundles/feature-workflow'); + + expect(screen.getByRole('status', { name: /loading readme/i })).toBeInTheDocument(); + expect(screen.queryByRole('status', { name: /loading bundle manifest/i })).not.toBeInTheDocument(); + expect(screen.getByTestId('bundle-detail-metadata')).toBeInTheDocument(); + expect(screen.getByTestId('bundle-detail-assets')).toBeInTheDocument(); + }); + + it('treats a README fetch failure as a missing README, not a page error', () => { + setBundle({ data: FULL_BUNDLE, isSuccess: true }); + setRegistry({ data: loadFixtureRegistry(), isSuccess: true }); + setReadme({ data: undefined, error: new Error('boom'), isError: true, isSuccess: false }); + + renderAt('/bundles/feature-workflow'); + + expect(screen.getByTestId('bundle-detail-readme-missing')).toBeInTheDocument(); + expect(screen.queryByTestId('bundle-detail-error')).not.toBeInTheDocument(); + }); + it('lists bundle.json plus each member file from the API listing under the bundle group', () => { setBundle({ data: FULL_BUNDLE, isSuccess: true }); setRegistry({ data: loadFixtureRegistry(), isSuccess: true }); @@ -240,6 +340,10 @@ describe('BundleDetailRoute', () => { expect(useBundleManifestMock).toHaveBeenCalledWith( expect.objectContaining({ name: 'qa-bundle', org: 'cupay', version: '2.0.0' }), ); + // The README hook receives the same org-scoped, registry-resolved ref. + expect(useBundleReadmeMock).toHaveBeenCalledWith( + expect.objectContaining({ name: 'qa-bundle', org: 'cupay', version: '2.0.0' }), + ); expect(screen.getByTestId('bundle-detail-org')).toHaveTextContent('cupay'); }); diff --git a/src/components/ReadmeSection.tsx b/src/components/ReadmeSection.tsx new file mode 100644 index 0000000..54bd0ef --- /dev/null +++ b/src/components/ReadmeSection.tsx @@ -0,0 +1,41 @@ +import { LoadingIndicator } from '@/components/LoadingIndicator'; +import { MarkdownRenderer } from '@/components/MarkdownRenderer'; + +interface ReadmeSectionProps { + /** Accessible name for the section, e.g. `Asset README`. */ + ariaLabel: string; + /** Copy shown when the README is absent or failed to load. */ + emptyMessage: string; + isError: boolean; + isLoading: boolean; + readme: null | string | undefined; + /** Section testid; the fallback paragraph uses `${testId}-missing`. */ + testId: string; +} + +/** + * The README block shared by the asset and bundle detail pages: heading, + * loading skeleton, "no README" fallback, and rendered markdown. + */ +export function ReadmeSection({ ariaLabel, emptyMessage, isError, isLoading, readme, testId }: ReadmeSectionProps) { + return ( +
+

README

+ +
+ ); +} + +function ReadmeBody({ emptyMessage, isError, isLoading, readme, testId }: Omit) { + if (isLoading) { + return ; + } + if (isError || !readme) { + return ( +

+ {emptyMessage} +

+ ); + } + return ; +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index eddd67f..6bb1091 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -2,6 +2,7 @@ export { useAssetFiles } from './useAssetFiles'; export { useAssetManifest } from './useAssetManifest'; export { useAssetReadme } from './useAssetReadme'; export { useBundleManifest } from './useBundleManifest'; +export { useBundleReadme } from './useBundleReadme'; export { useDownloadAsset } from './useDownloadAsset'; export { useDownloadBundle } from './useDownloadBundle'; export { useRegistry } from './useRegistry'; diff --git a/src/hooks/useBundleReadme.ts b/src/hooks/useBundleReadme.ts new file mode 100644 index 0000000..d472a92 --- /dev/null +++ b/src/hooks/useBundleReadme.ts @@ -0,0 +1,23 @@ +import { useQuery, type UseQueryResult } from '@tanstack/react-query'; + +import { useSession } from '@/hooks/useSession'; +import { queryKeys } from '@/lib/query-keys'; +import { type BundleManifestRef, fetchBundleReadme } from '@/lib/registry-client'; + +/** + * Fetch and cache a bundle's README.md. Requires an authenticated session. + * Resolves to `null` when the README is missing (HTTP 404). + */ +export function useBundleReadme(ref: Partial): UseQueryResult { + const { api } = useSession(); + const enabled = Boolean(api && ref.name && ref.version); + + return useQuery({ + enabled, + queryFn: ({ signal }) => { + if (!api) throw new Error('useBundleReadme: no authenticated API client available'); + return fetchBundleReadme(ref as BundleManifestRef, { client: api, signal }); + }, + queryKey: queryKeys.bundleReadme({ name: ref.name ?? '', org: ref.org, version: ref.version }), + }); +} diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index ad085d0..47d85e7 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -11,6 +11,8 @@ export const queryKeys = { ['registry', 'asset-readme', ref.type, ref.org ?? '', ref.name, ref.version] as const, bundleManifest: (ref: { name: string; org?: string; version?: string }) => ['registry', 'bundle-manifest', ref.org ?? '', ref.name, ref.version ?? ''] as const, + bundleReadme: (ref: { name: string; org?: string; version?: string }) => + ['registry', 'bundle-readme', ref.org ?? '', ref.name, ref.version ?? ''] as const, registry: () => ['registry', 'index'] as const, session: { membership: (org: string, username: string) => ['session', 'membership', org, username] as const, diff --git a/src/lib/registry-client.ts b/src/lib/registry-client.ts index 05f3fb2..96522f8 100644 --- a/src/lib/registry-client.ts +++ b/src/lib/registry-client.ts @@ -2,7 +2,14 @@ import type { ZodType } from 'zod'; import { z } from 'zod'; -import { getAssetManifest, getAssetReadme, getBundleManifest, getRegistry, listAssetFiles } from './api'; +import { + getAssetManifest, + getAssetReadme, + getBundleManifest, + getBundleReadme, + getRegistry, + listAssetFiles, +} from './api'; import { type ApiClient, ApiRequestError, type ApiResult, unwrap } from './api-client'; import { RegistryFetchError, RegistryNotFoundError, RegistryParseError } from './registry-errors'; import { AssetType, type Bundle, BundleSchema, type Manifest, ManifestSchema } from './schemas'; @@ -104,7 +111,7 @@ export async function fetchAssetReadme(ref: AssetManifestRef, options: RegistryC /** Fetch and validate a bundle's `bundle.json` from its versioned registry path. */ export async function fetchBundleManifest(ref: BundleManifestRef, options: RegistryClientOptions): Promise { - const label = `bundle ${ref.org ? `@${ref.org}/` : ''}${ref.name}@${ref.version} manifest`; + const label = `${describeBundle(ref)} manifest`; const result = await getBundleManifest({ client: options.client, path: { name: ref.name, version: ref.version }, @@ -114,6 +121,26 @@ export async function fetchBundleManifest(ref: BundleManifestRef, options: Regis return parseResponse(unwrapRegistry(result, label), BundleSchema, label); } +/** + * Fetch a bundle's `README.md` as raw markdown. Returns null when the README + * is absent (HTTP 404) so callers can degrade gracefully. + */ +export async function fetchBundleReadme( + ref: BundleManifestRef, + options: RegistryClientOptions, +): Promise { + const result = await getBundleReadme({ + client: options.client, + parseAs: 'text', + path: { name: ref.name, version: ref.version }, + query: orgQuery(ref.org), + signal: options.signal, + }); + if (result.response?.status === 404) return null; + const data = unwrapRegistry(result, `${describeBundle(ref)} README`); + return typeof data === 'string' ? data : String(data); +} + /** Fetch and validate the registry index (`registry.json`) from the ATK API. */ export async function fetchRegistry(options: RegistryClientOptions): Promise { const label = 'the registry index'; @@ -162,6 +189,10 @@ function describeAsset(ref: AssetManifestRef): string { return `${ref.type} ${ref.org ? `@${ref.org}/` : ''}${ref.name}@${ref.version}`; } +function describeBundle(ref: BundleManifestRef): string { + return `bundle ${ref.org ? `@${ref.org}/` : ''}${ref.name}@${ref.version}`; +} + function orgQuery(org: string | undefined): undefined | { org?: string } { return org ? { org } : undefined; } diff --git a/src/routes/AssetDetail.tsx b/src/routes/AssetDetail.tsx index 33bce6b..5cd2577 100644 --- a/src/routes/AssetDetail.tsx +++ b/src/routes/AssetDetail.tsx @@ -10,8 +10,8 @@ import { DownloadMenu } from '@/components/DownloadMenu'; import { EmptyState } from '@/components/EmptyState'; import { type FileGroup, FilesCard } from '@/components/FilesCard'; import { LoadingIndicator } from '@/components/LoadingIndicator'; -import { MarkdownRenderer } from '@/components/MarkdownRenderer'; import { PageHeader } from '@/components/PageHeader'; +import { ReadmeSection } from '@/components/ReadmeSection'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { useAssetFiles } from '@/hooks/useAssetFiles'; @@ -230,10 +230,14 @@ export function AssetDetailRoute() { testId='asset-detail-files' /> -
-

README

- -
+ ); } @@ -318,20 +322,6 @@ function MetadataRow({ children, label }: { children: React.ReactNode; label: st ); } -function ReadmeView({ isError, isLoading, readme }: { isError: boolean; isLoading: boolean; readme: null | string }) { - if (isLoading) { - return ; - } - if (isError || !readme) { - return ( -

- No README is available for this asset. -

- ); - } - return ; -} - function SecurityBlockView({ security }: { security: NonNullable }) { return (
diff --git a/src/routes/BundleDetail.tsx b/src/routes/BundleDetail.tsx index 2af2dd1..7859fe8 100644 --- a/src/routes/BundleDetail.tsx +++ b/src/routes/BundleDetail.tsx @@ -11,10 +11,12 @@ import { type FileGroup, FilesCard } from '@/components/FilesCard'; import { LoadingIndicator } from '@/components/LoadingIndicator'; import { MarkdownRenderer } from '@/components/MarkdownRenderer'; import { PageHeader } from '@/components/PageHeader'; +import { ReadmeSection } from '@/components/ReadmeSection'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { useBundleManifest } from '@/hooks/useBundleManifest'; +import { useBundleReadme } from '@/hooks/useBundleReadme'; import { useDownloadBundle } from '@/hooks/useDownloadBundle'; import { refKey as manifestRefKey, useManifestGraph } from '@/hooks/useManifestGraph'; import { useRegistry } from '@/hooks/useRegistry'; @@ -46,6 +48,7 @@ export function BundleDetailRoute() { [registryQuery.data, bundleName, bundleOrg], ); const manifestQuery = useBundleManifest({ name: bundleName, org: bundleOrg, version: bundleVersion }); + const readmeQuery = useBundleReadme({ name: bundleName, org: bundleOrg, version: bundleVersion }); const { download, isDownloading } = useDownloadBundle(); const displayName = bundleOrg && bundleName ? `@${bundleOrg}/${bundleName}` : bundleName; @@ -243,6 +246,15 @@ export function BundleDetailRoute() { + + {manifest.setupInstructions ? (

Setup instructions