');
+ expect(container.textContent).toContain('Creators: Alice');
+ });
+
+ // End-to-end for readest issue #4749: a feed lists this book with only a
+ // `rel="self"` publication link (no download, no description). Dereferencing
+ // that link yields the document below; rendering it must surface the
+ // acquisition button, the HTML description as markup, and the metadata — the
+ // same outcome Thorium produces.
+ it('renders a fetched OPDS 2.0 publication document with downloads and HTML description', () => {
+ const document = JSON.stringify({
+ metadata: {
+ title: 'Weeds used in medicine',
+ author: { name: 'Henkel, Alice' },
+ publisher: 'Washington: Government printing office, 1904.',
+ language: 'en',
+ description: '
');
+ // Metadata only present in the fetched document is shown.
+ expect(screen.getByText('Washington: Government printing office, 1904.')).toBeTruthy();
+ });
+
it('renders a tag without an OPDS link as plain, non-clickable text', () => {
render(
diff --git a/apps/readest-app/src/app/opds/components/PublicationView.tsx b/apps/readest-app/src/app/opds/components/PublicationView.tsx
index 24731c2f..5cadfc30 100644
--- a/apps/readest-app/src/app/opds/components/PublicationView.tsx
+++ b/apps/readest-app/src/app/opds/components/PublicationView.tsx
@@ -175,7 +175,14 @@ export function PublicationView({
const content = publication.metadata?.[SYMBOL.CONTENT] || publication.metadata?.content;
const description = publication.metadata?.description;
- const descriptionHtml = useMemo(() => getOPDSDescriptionHtml(content), [content]);
+ // OPDS 2.0 JSON keeps the summary in the plain `description` string (no typed
+ // ), and catalogs like pglaf/Gutenberg fill it with HTML. Fall back
+ // to it so that markup renders as markup instead of literal tags; the helper
+ // sanitizes either source (readest issue #4749).
+ const descriptionHtml = useMemo(
+ () => getOPDSDescriptionHtml(content ?? description),
+ [content, description],
+ );
return (
diff --git a/apps/readest-app/src/app/opds/page.tsx b/apps/readest-app/src/app/opds/page.tsx
index dae5db59..d5362444 100644
--- a/apps/readest-app/src/app/opds/page.tsx
+++ b/apps/readest-app/src/app/opds/page.tsx
@@ -42,6 +42,7 @@ import {
needsProxy,
probeFilename,
} from './utils/opdsReq';
+import { getPublicationDetailHref, parsePublicationDocument } from './utils/opdsPublication';
import { ImportError } from '@/services/errors';
import { READEST_OPDS_USER_AGENT } from '@/services/constants';
import { findBookByOPDSSources, upsertOPDSSourceMapping } from '@/services/opds/sourceMap';
@@ -474,13 +475,78 @@ export default function BrowserPage() {
[_, state, handleNavigate, addToHistory],
);
- const publication =
+ const basePublication =
selectedPublication && state.feed
? state.feed.groups?.[selectedPublication.groupIndex]?.publications?.[
selectedPublication.itemIndex
] || state.feed.publications?.[selectedPublication.itemIndex]
: state.publication;
+ const fetchPublicationDocument = useCallback(
+ async (url: string): Promise => {
+ try {
+ const useProxy = isWebAppPlatform();
+ const username = usernameRef.current || '';
+ const password = passwordRef.current || '';
+ const customHeaders = customHeadersRef.current;
+ const res = await fetchWithAuth(url, username, password, useProxy, {}, customHeaders);
+ if (!res.ok) return null;
+ const text = await res.text();
+ return parsePublicationDocument(text, res.url);
+ } catch (e) {
+ console.warn('Failed to load OPDS publication document:', e);
+ return null;
+ }
+ },
+ [],
+ );
+
+ // When a publication is listed in summary form but advertises a `rel="self"`
+ // link to its full document, dereference it and show the richer metadata
+ // (description, publisher, subjects, language) — readest issue #4749, matching
+ // what Thorium does. The summary renders immediately and is upgraded in place
+ // once the document loads; `source` ties the resolved record to the exact
+ // base publication it enriches so a stale fetch can't bleed into the next one.
+ const [detailPublication, setDetailPublication] = useState<{
+ source: OPDSPublication;
+ resolved: OPDSPublication;
+ } | null>(null);
+
+ useEffect(() => {
+ // Only enrich feed-selected summaries; a directly-loaded entry document
+ // (state.publication, no selection) is already the full record.
+ const detailLink =
+ selectedPublication && basePublication
+ ? getPublicationDetailHref(basePublication)
+ : undefined;
+ if (!basePublication || !detailLink) {
+ setDetailPublication(null);
+ return;
+ }
+ let cancelled = false;
+ const url = resolveURL(detailLink.href, state.baseURL);
+ void fetchPublicationDocument(url).then((resolved) => {
+ if (!cancelled && resolved) {
+ setDetailPublication({ source: basePublication, resolved });
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [selectedPublication, basePublication, state.baseURL, fetchPublicationDocument]);
+
+ const publication = useMemo(() => {
+ if (!basePublication || detailPublication?.source !== basePublication) return basePublication;
+ const { resolved } = detailPublication;
+ // Prefer the full document's metadata; keep the feed's links/cover when the
+ // document omits them so downloads and the cover never regress.
+ return {
+ metadata: resolved.metadata,
+ links: resolved.links?.length ? resolved.links : basePublication.links,
+ images: resolved.images?.length ? resolved.images : basePublication.images,
+ };
+ }, [basePublication, detailPublication]);
+
const handleDownload = useCallback(
async (
href: string,
diff --git a/apps/readest-app/src/app/opds/utils/opdsPublication.ts b/apps/readest-app/src/app/opds/utils/opdsPublication.ts
new file mode 100644
index 00000000..cb2f490c
--- /dev/null
+++ b/apps/readest-app/src/app/opds/utils/opdsPublication.ts
@@ -0,0 +1,72 @@
+import { getPublication } from 'foliate-js/opds.js';
+import type { OPDSBaseLink, OPDSPublication } from '@/types/opds';
+import { looksLikeXMLContent, MIME, parseMediaType, parseOPDSXML, resolveURL } from './opdsUtils';
+
+// Media type of a standalone OPDS 2.0 publication document.
+const OPDS_PUBLICATION_JSON = 'application/opds-publication+json';
+
+/**
+ * Find a publication's canonical document link (`rel="self"`) that dereferences
+ * to a *full* OPDS publication record. OPDS feeds frequently list publications
+ * in summary form (title + cover + acquisition links) and carry the complete
+ * metadata — description, publisher, subjects, language — only in the standalone
+ * publication document this link points at (readest issue #4749, matching what
+ * Thorium shows). Returns its href/type, or undefined when the publication has
+ * no such link.
+ *
+ * Recognized document types: OPDS 2.0 JSON (`application/opds-publication+json`)
+ * and an Atom catalog entry (`application/atom+xml;type=entry`).
+ */
+export const getPublicationDetailHref = (
+ publication: OPDSPublication,
+): { href: string; type?: string } | undefined => {
+ for (const link of publication.links ?? []) {
+ const rels = Array.isArray(link.rel) ? link.rel : [link.rel ?? ''];
+ if (!link.href || !rels.includes('self')) continue;
+ const parsed = parseMediaType(link.type);
+ if (!parsed) continue;
+ const { mediaType, parameters } = parsed;
+ if (mediaType === OPDS_PUBLICATION_JSON) return { href: link.href, type: link.type };
+ if (mediaType === MIME.ATOM && parameters['type'] === 'entry') {
+ return { href: link.href, type: link.type };
+ }
+ }
+ return undefined;
+};
+
+const absolutizeLinks = (
+ links: T[] | undefined,
+ docURL: string,
+): T[] | undefined =>
+ links?.map((link) => (link.href ? { ...link, href: resolveURL(link.href, docURL) } : link));
+
+/**
+ * Parse a fetched OPDS publication document (the body behind a detail link, see
+ * getPublicationDetailHref) into an OPDSPublication. Supports OPDS 2.0 JSON and
+ * Atom entry XML. Link and image hrefs are resolved to absolute URLs against the
+ * document's own URL so the detail view keeps resolving downloads and the cover
+ * correctly even though it carries the original feed's base URL. Returns null
+ * when the body is not a recognizable single publication.
+ */
+export const parsePublicationDocument = (text: string, docURL: string): OPDSPublication | null => {
+ let publication: OPDSPublication | null = null;
+ if (looksLikeXMLContent(text)) {
+ const doc = parseOPDSXML(text);
+ if (doc.documentElement?.localName !== 'entry') return null;
+ publication = getPublication(doc.documentElement) as OPDSPublication;
+ } else {
+ let json: unknown;
+ try {
+ json = JSON.parse(text);
+ } catch {
+ return null;
+ }
+ if (!json || typeof json !== 'object' || !('metadata' in json)) return null;
+ publication = json as OPDSPublication;
+ }
+ return {
+ metadata: publication.metadata,
+ links: absolutizeLinks(publication.links, docURL) ?? [],
+ images: absolutizeLinks(publication.images, docURL) ?? [],
+ };
+};