Documentation Index
Fetch the complete documentation index at: https://mintlify.com/johnfactotum/foliate-js/llms.txt
Use this file to discover all available pages before exploring further.
OPDS (Open Publication Distribution System) is a catalog format for e-books, used by libraries, publishers, and distributors to expose browsable and searchable collections over HTTP. The opds.js module normalizes OPDS 1.x Atom feeds into the OPDS 2.0 JSON structure so your application only needs to handle one data shape, regardless of which version the server speaks.
Converting an OPDS 1.x feed
getFeed(doc) accepts a DOM Document — the parsed Atom XML of an OPDS 1.x feed — and returns an OPDS 2.0-shaped feed object.
import { getFeed } from './foliate-js/opds.js'
const response = await fetch('https://catalog.example.com/opds')
const text = await response.text()
const doc = new DOMParser().parseFromString(text, 'application/xml')
const feed = getFeed(doc)
console.log(feed.metadata.title) // feed title
console.log(feed.navigation) // array of navigation links (if present)
console.log(feed.publications) // array of OPDS 2.0 publication objects (if present)
console.log(feed.groups) // grouped entries
console.log(feed.facets) // facet links for filtering
The returned object follows the OPDS 2.0 feed structure:
{
metadata: { title, subtitle },
links: [...], // feed-level links (self, next, search, etc.)
navigation: [...], // navigation entries
publications: [...], // acquisition entries, each shaped like getPublication()
groups: [...], // grouped collections within the feed
facets: [...] // filtering options
}
Converting a single OPDS 1.x entry
getPublication(entry) converts a single <entry> element from an acquisition feed into an OPDS 2.0 publication object. Pass it a DOM Element.
import { getPublication } from './foliate-js/opds.js'
// entry is a DOM Element — e.g. from querySelectorAll('entry')
const pub = getPublication(entry)
console.log(pub.metadata.title)
console.log(pub.metadata.author) // array of { name, links }
console.log(pub.metadata.language)
console.log(pub.links) // acquisition, sample, etc.
console.log(pub.images) // cover and thumbnail links
The pub.links array follows the OPDS 2.0 link structure. Acquisition links carry .properties.price (an object with currency and value) and .properties.indirectAcquisition (an array of intermediate format steps).
Symbol exports for non-standard properties
OPDS 1.x supports richer content than OPDS 2.0 can represent natively. opds.js uses JavaScript Symbol keys to preserve this information:
import { SYMBOL } from './foliate-js/opds.js'
// On navigation links: the summary or content text of the entry
const summary = navLink[SYMBOL.SUMMARY]
// On publications: the full content field with its MIME type
const content = pub.metadata[SYMBOL.CONTENT]
// content is { type: 'text' | 'html' | 'xhtml', value: string }
Use SYMBOL.CONTENT when you need to preserve the distinction between plain text, HTML, and XHTML descriptions — for example, to sanitize and render rich descriptions correctly.
Implementing search
OpenSearch (OPDS 1.x)
OPDS 1.x catalogs advertise search via an OpenSearch document linked from the feed. Fetch the OpenSearch document, then call getOpenSearch(doc):
import { getOpenSearch } from './foliate-js/opds.js'
// Find the search link in the feed
const searchLink = feed.links.find(l => l.rel?.includes('search'))
const searchDoc = await fetch(searchLink.href)
.then(r => r.text())
.then(t => new DOMParser().parseFromString(t, 'application/xml'))
const search = getOpenSearch(searchDoc)
console.log(search.metadata.title) // "Search the catalog"
console.log(search.metadata.description)
console.log(search.params) // array of parameter descriptors
// Build a search URL
const map = new Map([[null, new Map([['searchTerms', 'Hemingway']])]])
const url = search.search(map) // returns a URL string
Templated search (OPDS 2.0)
For OPDS 2.0 catalogs that use URI Templates, call getSearch(link) with the OPDS 2.0 link object:
import { getSearch } from './foliate-js/opds.js'
const searchLink = feed.links.find(l => l.rel?.includes('search'))
const search = await getSearch(searchLink)
const map = new Map([[null, new Map([['query', 'Tolkien']])]])
const url = search.search(map)
getSearch() is async because it dynamically imports uri-template.js for URI Template expansion.
The search interface
Both getOpenSearch and getSearch return an object with the same shape:
{
metadata: {
title: 'Search',
description: 'Search the catalog by keyword' // getOpenSearch only
},
params: [
{
ns: null, // namespace URI string, or null for unnamespaced
name: 'searchTerms', // parameter name
required: true, // whether the parameter is required
value: '' // default value
},
// ...
],
search: (map) => 'https://...' // builds the URL
}
The search(map) function takes a two-dimensional Map:
- Outer key: the namespace URI string, or
null for parameters with no namespace
- Inner key: the parameter name
- Inner value: the value to substitute
const map = new Map([
[null, new Map([
['searchTerms', 'open access'],
['count', '20'],
])]
])
const url = search.search(map)
Complete example: browsing a catalog feed
import { getFeed, SYMBOL } from './foliate-js/opds.js'
async function loadFeed(url) {
const response = await fetch(url)
const contentType = response.headers.get('content-type') ?? ''
if (contentType.includes('application/opds+json')) {
// OPDS 2.0: already JSON
return response.json()
}
// OPDS 1.x: parse Atom XML
const text = await response.text()
const doc = new DOMParser().parseFromString(text, 'application/xml')
return getFeed(doc)
}
async function renderFeed(url) {
const feed = await loadFeed(url)
const container = document.getElementById('catalog')
container.innerHTML = `<h1>${feed.metadata.title ?? 'Catalog'}</h1>`
// Navigation links (sub-catalogs)
if (feed.navigation?.length) {
const nav = document.createElement('ul')
for (const link of feed.navigation) {
const li = document.createElement('li')
const a = document.createElement('a')
a.href = '#'
a.textContent = link.title ?? link.href
a.addEventListener('click', e => {
e.preventDefault()
renderFeed(link.href)
})
if (link[SYMBOL.SUMMARY]) {
const p = document.createElement('p')
p.textContent = link[SYMBOL.SUMMARY]
li.append(a, p)
} else {
li.append(a)
}
nav.append(li)
}
container.append(nav)
}
// Acquisition entries (books)
if (feed.publications?.length) {
const list = document.createElement('div')
list.className = 'book-grid'
for (const pub of feed.publications) {
const card = document.createElement('article')
const cover = pub.images[0]
if (cover) {
const img = document.createElement('img')
img.src = cover.href
img.alt = ''
card.append(img)
}
const title = document.createElement('h2')
title.textContent = pub.metadata.title
const authors = pub.metadata.author.map(a => a.name).join(', ')
const byline = document.createElement('p')
byline.textContent = authors
card.append(title, byline)
list.append(card)
}
container.append(list)
}
// Pagination
const nextLink = feed.links.find(l => l.rel?.includes('next'))
if (nextLink) {
const btn = document.createElement('button')
btn.textContent = 'Load more'
btn.addEventListener('click', () => renderFeed(nextLink.href))
container.append(btn)
}
}
renderFeed('https://catalog.example.com/opds')
OPDS servers may return Atom feeds without a profile=opds-catalog parameter on the Content-Type header, which can make feed detection unreliable. You can use the exported isOPDSCatalog(contentTypeString) function to check whether a response is an OPDS catalog before parsing.