diff --git a/apps/readest-app/src/__tests__/app/opds/opds-utils.test.ts b/apps/readest-app/src/__tests__/app/opds/opds-utils.test.ts
index 79dc22ab..7ca94eda 100644
--- a/apps/readest-app/src/__tests__/app/opds/opds-utils.test.ts
+++ b/apps/readest-app/src/__tests__/app/opds/opds-utils.test.ts
@@ -4,8 +4,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
vi.mock('foliate-js/opds.js', () => ({
isOPDSCatalog: vi.fn((type: string) => {
return (
- type.includes('application/atom+xml') &&
- (type.includes('opds-catalog') || type.includes('opds'))
+ type.includes('application/opds+json') ||
+ (type.includes('application/atom+xml') &&
+ (type.includes('opds-catalog') || type.includes('opds')))
);
}),
}));
@@ -54,6 +55,7 @@ import {
parseOPDSXML,
MIME,
validateOPDSURL,
+ getOPDSNavLink,
} from '@/app/opds/utils/opdsUtils';
import type { OPDSBaseLink } from '@/types/opds';
import { fetchWithAuth } from '@/app/opds/utils/opdsReq';
@@ -747,4 +749,36 @@ describe('opdsUtils', () => {
);
});
});
+
+ describe('getOPDSNavLink', () => {
+ it('returns the href of the first OPDS-typed link', () => {
+ expect(
+ getOPDSNavLink([{ href: '/opds/subjects?id=164', type: 'application/opds+json' }]),
+ ).toBe('/opds/subjects?id=164');
+ });
+
+ it('skips links whose type is not an OPDS catalog type', () => {
+ expect(
+ getOPDSNavLink([{ href: 'https://example.com/author', type: 'text/html' }]),
+ ).toBeUndefined();
+ });
+
+ it('returns the first OPDS link when mixed with non-OPDS links', () => {
+ expect(
+ getOPDSNavLink([
+ { href: 'https://example.com/author', type: 'text/html' },
+ { href: '/opds/search?author_id=52836', type: 'application/opds+json' },
+ ]),
+ ).toBe('/opds/search?author_id=52836');
+ });
+
+ it('returns undefined for an empty or missing array', () => {
+ expect(getOPDSNavLink([])).toBeUndefined();
+ expect(getOPDSNavLink(undefined)).toBeUndefined();
+ });
+
+ it('ignores entries without an href', () => {
+ expect(getOPDSNavLink([{ type: 'application/opds+json' }])).toBeUndefined();
+ });
+ });
});
diff --git a/apps/readest-app/src/__tests__/app/opds/publication-view.test.tsx b/apps/readest-app/src/__tests__/app/opds/publication-view.test.tsx
index f7caae56..2c115cd9 100644
--- a/apps/readest-app/src/__tests__/app/opds/publication-view.test.tsx
+++ b/apps/readest-app/src/__tests__/app/opds/publication-view.test.tsx
@@ -39,6 +39,27 @@ const publication: OPDSPublication = {
images: [],
};
+const linkedPublication: OPDSPublication = {
+ metadata: {
+ title: 'Linked Entry',
+ author: [
+ {
+ name: 'Tuttle',
+ links: [{ href: '/opds/search?author_id=52836', type: 'application/opds+json' }],
+ },
+ ],
+ subject: [
+ {
+ name: 'Horses -- Fiction',
+ links: [{ href: '/opds/subjects?id=164', type: 'application/opds+json' }],
+ },
+ { name: 'Plain Tag' },
+ ],
+ },
+ links: [],
+ images: [],
+};
+
const existingBook: Book = {
hash: 'existing-book',
format: 'EPUB',
@@ -65,6 +86,7 @@ describe('PublicationView', () => {
existingBook={existingBook}
resolveURL={(href, base) => new URL(href, base).toString()}
onDownload={onDownload}
+ onNavigate={vi.fn()}
onGenerateCachedImageUrl={vi.fn(async (url: string) => url)}
/>
,
@@ -81,4 +103,64 @@ describe('PublicationView', () => {
});
expect(navigateToReader).not.toHaveBeenCalled();
});
+
+ it('navigates when a tag with an OPDS link is clicked', () => {
+ const onNavigate = vi.fn();
+
+ render(
+
+ new URL(href, base).toString()}
+ onDownload={vi.fn(async () => null)}
+ onNavigate={onNavigate}
+ onGenerateCachedImageUrl={vi.fn(async (url: string) => url)}
+ />
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: /Horses -- Fiction/ }));
+ expect(onNavigate).toHaveBeenCalledWith('https://gutenberg.example.com/opds/subjects?id=164');
+ });
+
+ it('navigates when an author with an OPDS link is clicked', () => {
+ const onNavigate = vi.fn();
+
+ render(
+
+ new URL(href, base).toString()}
+ onDownload={vi.fn(async () => null)}
+ onNavigate={onNavigate}
+ onGenerateCachedImageUrl={vi.fn(async (url: string) => url)}
+ />
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Tuttle' }));
+ expect(onNavigate).toHaveBeenCalledWith(
+ 'https://gutenberg.example.com/opds/search?author_id=52836',
+ );
+ });
+
+ it('renders a tag without an OPDS link as plain, non-clickable text', () => {
+ render(
+
+ new URL(href, base).toString()}
+ onDownload={vi.fn(async () => null)}
+ onNavigate={vi.fn()}
+ onGenerateCachedImageUrl={vi.fn(async (url: string) => url)}
+ />
+ ,
+ );
+
+ expect(screen.queryByRole('button', { name: 'Plain Tag' })).toBeNull();
+ expect(screen.getByText('Plain Tag')).toBeTruthy();
+ });
});
diff --git a/apps/readest-app/src/app/opds/components/PublicationView.tsx b/apps/readest-app/src/app/opds/components/PublicationView.tsx
index fd4a1265..6afa112e 100644
--- a/apps/readest-app/src/app/opds/components/PublicationView.tsx
+++ b/apps/readest-app/src/app/opds/components/PublicationView.tsx
@@ -13,7 +13,7 @@ import { getImportErrorMessage, ImportError } from '@/services/errors';
import { eventDispatcher } from '@/utils/event';
import { navigateToReader } from '@/utils/nav';
import { CachedImage } from '@/components/CachedImage';
-import { groupByArray } from '../utils/opdsUtils';
+import { groupByArray, getOPDSNavLink } from '../utils/opdsUtils';
import { getOPDSDescriptionHtml } from '../utils/opdsContent';
import Dropdown from '@/components/Dropdown';
import MenuItem from '@/components/MenuItem';
@@ -31,6 +31,7 @@ interface PublicationViewProps {
*/
existingBook?: Book | null;
resolveURL: (url: string, base: string) => string;
+ onNavigate: (url: string) => void;
onDownload: (
href: string,
type?: string,
@@ -45,6 +46,7 @@ export function PublicationView({
baseURL,
existingBook,
resolveURL,
+ onNavigate,
onDownload,
onStream,
onGenerateCachedImageUrl,
@@ -92,13 +94,21 @@ export function PublicationView({
const authors = useMemo(() => {
const author = publication.metadata?.author;
- if (!author) return undefined;
+ if (!author) return [] as Array<{ name: string; href: string | undefined }>;
const authorList = Array.isArray(author) ? author : [author];
- return authorList.map((a) => (typeof a === 'string' ? a : a?.name)).filter(Boolean);
+ return authorList
+ .map((a) =>
+ typeof a === 'string'
+ ? { name: a, href: undefined as string | undefined }
+ : { name: a?.name, href: getOPDSNavLink(a?.links) },
+ )
+ .filter((a): a is { name: string; href: string | undefined } => Boolean(a.name));
}, [publication.metadata?.author]);
+ const authorNames = useMemo(() => authors.map((a) => a.name), [authors]);
+
const acquisitionLinks = useMemo(() => {
const links: Array<{ rel: string; links: OPDSAcquisitionLink[] }> = [];
for (const [rel, linkList] of Array.from(linksByRel.entries())) {
@@ -191,8 +201,25 @@ export function PublicationView({
{publication.metadata?.title || 'Untitled'}
- {authors && authors.length > 0 && (
- {authors.join(', ')}
+ {authors.length > 0 && (
+
+ {authors.map((author, index) => (
+
+ {index > 0 && ', '}
+ {author.href ? (
+
+ ) : (
+ author.name
+ )}
+
+ ))}
+
)}
@@ -288,7 +315,7 @@ export function PublicationView({
link.href!,
count,
publication.metadata?.title || '',
- authors?.join(', ') || '',
+ authorNames.join(', '),
)
}
disabled={downloading || !!downloadedBook}
@@ -399,12 +426,29 @@ export function PublicationView({
{publication.metadata.subject.map((subject, index: number) => {
const tag =
typeof subject === 'string' ? subject : subject.name || subject.code || _('Tag');
- return (
-
+ const href =
+ typeof subject === 'string' ? undefined : getOPDSNavLink(subject.links);
+ const badgeClass = 'badge badge-outline max-w-full gap-1';
+ const inner = (
+ <>
{tag}
+ >
+ );
+ return href ? (
+
+ ) : (
+
+ {inner}
);
})}
diff --git a/apps/readest-app/src/app/opds/page.tsx b/apps/readest-app/src/app/opds/page.tsx
index ed1b971b..1822da35 100644
--- a/apps/readest-app/src/app/opds/page.tsx
+++ b/apps/readest-app/src/app/opds/page.tsx
@@ -957,6 +957,7 @@ export default function BrowserPage() {
onDownload={handleDownload}
onStream={handleStream}
resolveURL={resolveURL}
+ onNavigate={handleNavigate}
onGenerateCachedImageUrl={handleGenerateCachedImageUrl}
/>
)}
diff --git a/apps/readest-app/src/app/opds/utils/opdsUtils.ts b/apps/readest-app/src/app/opds/utils/opdsUtils.ts
index 7d79e68f..4a3e0ae1 100644
--- a/apps/readest-app/src/app/opds/utils/opdsUtils.ts
+++ b/apps/readest-app/src/app/opds/utils/opdsUtils.ts
@@ -128,6 +128,17 @@ export const parseOPDSXML = (text: string): Document => {
return doc;
};
+/**
+ * Return the first OPDS-navigable href from a links array (e.g. on an OPDS 2.0
+ * JSON author or subject). Only links whose `type` is an OPDS catalog type
+ * (per foliate-js `isOPDSCatalog`, e.g. `application/opds+json`) qualify, so a
+ * non-OPDS link such as an author's external homepage is ignored. Returns
+ * undefined when no such link exists.
+ */
+export const getOPDSNavLink = (
+ links?: Array<{ href?: string; type?: string }>,
+): string | undefined => links?.find((link) => link.href && isOPDSCatalog(link.type ?? ''))?.href;
+
export const isSearchLink = (link: OPDSBaseLink): boolean => {
const rels = Array.isArray(link.rel) ? link.rel : [link.rel || ''];
if (!rels.includes('search')) return false;
diff --git a/apps/readest-app/src/types/opds.ts b/apps/readest-app/src/types/opds.ts
index 5714763c..1efc142a 100644
--- a/apps/readest-app/src/types/opds.ts
+++ b/apps/readest-app/src/types/opds.ts
@@ -117,13 +117,14 @@ export interface OPDSBaseLink {
interface OPDSPerson {
name?: string;
- links: Array<{ href: string }>;
+ links: Array<{ href: string; type?: string }>;
}
interface OPDSSubject {
name?: string;
code?: string;
scheme?: string;
+ links?: Array<{ href: string; type?: string }>;
}
export interface OPDSContent {