@sdxc/opml
Read and write OPML subscription lists
- Installs with
- @sdxc/result@sdxc/xml
- Used by
- reader
- Source
- packages/opml
Read and write OPML subscription lists.
OPML is how someone moves between feed readers: one exports the list, the other imports
it. This package is both halves of that, parse and stringify, over a document shape
that has barely changed in twenty years.
Reading is deliberately forgiving. A real export is a tree, because readers let people file feeds into folders, and its attributes are spelled however the exporter felt like spelling them. Reading flattens the tree, keeps the feeds inside the folders, carries the name of the nearest folder around each feed, and falls through the attributes an outline might carry its title in, so what comes back is the flat list an import actually wants, filed the way it arrived.
Writing is deliberately plain: valid OPML 2.0, one <outline> per subscription, every
value escaped by the XML layer. A document this package writes reads back as the
subscriptions that went into it.
Installation
npm add @sdxc/opml
The XML layer beneath it and the Result that reading returns, from
@sdxc/result, install alongside this
package.
Usage
Import A Subscription List
import { parse } from "@sdxc/opml";
import { isFailure } from "@sdxc/result";
let result = parse(await file.text());
if (isFailure(result)) throw result.error;
let outlines = result.data;
if (outlines.length === 0) return reportNothingToImport();
for (let outline of outlines) {
await subscribe(outline.feedUrl, { title: outline.title, siteUrl: outline.siteUrl });
}
An empty list is a success, not a failure: a well-formed document that happens to list no feeds is a different thing from a file that is not a subscription list, and only the caller knows what to say about each.
Export One
import { stringify } from "@sdxc/opml";
let source = stringify(
subscriptions.map((subscription) => ({
title: subscription.title,
feedUrl: subscription.feedUrl,
siteUrl: subscription.siteUrl,
})),
{ title: "Subscriptions", dateCreated: new Date() },
);
return new Response(source, {
headers: {
"Content-Type": "text/x-opml",
"Content-Disposition": `attachment; filename="subscriptions.opml"`,
},
});
API
parse(source: string): Result<OPML.Outline[], OPMLParseError>
Reads every subscription a document lists, in document order.
Outlines nest, and every level is visited, so a grouped export yields the same list as a flat one.
An outline with no
xmlUrlis a folder: it is skipped, and the feeds under it are not.folderis the name of the nearest enclosing folder, so a feed two levels down carries the name written directly over it rather than the outermost one or a joined path.The title comes from
text, thentitle, thenhtmlUrl, thenxmlUrl, so every subscription carries something a row can be labelled with.siteUrlcomes fromhtmlUrl, and is absent when the outline named no site.A feed listed more than once appears once, at its first position.
Values are trimmed, and attribute names are matched without regard to case.
stringify(outlines: OPML.Outline[], options?: OPML.StringifyOptions): string
Writes subscriptions as an OPML 2.0 document with the head/body structure.
options.title— the document's own title, which a reader shows when importing it.options.dateCreated— when the document was written, emitted in the format the head reads.
Subscriptions carrying a folder are written inside one <outline> per folder, folders
in the order their names read, and the rest at the top level after them.
Errors
OPMLParseError reports text that is not XML, or XML whose root element says it is not
OPML. Nothing a document contains below the root is an error.
Types
OPML.Outline is { title: string; feedUrl: string; siteUrl?: string; folder?: string }.
OPML.StringifyOptions is { title?: string; dateCreated?: Date }.
Pattern: Round-Tripping A List
Reading back a document this package wrote returns the outlines it was given, so an export and the import beside it can be tested against each other without a fixture:
import { parse, stringify } from "@sdxc/opml";
import { unwrap } from "@sdxc/result";
let outlines = [{ title: "Tom & Jerry", feedUrl: "https://example.com/feed?a=1&b=2" }];
expect(unwrap(parse(stringify(outlines)))).toEqual(outlines);
Notes
Only the root element decides whether a document is OPML. A file whose root is anything else fails, which is how an error page served under a
200in an export's place reports itself rather than reading as a list with nothing in it.Outlines are found anywhere under the root. The specification puts them in
<body>, and exports that omit it, or nest one oddly, still yield their feeds.The title is written twice, to
textandtitle. The specification requirestext; readers exist that label a row fromtitlealone, and one value fills both.Only subscription outlines survive a round trip. An outline type OPML also allows — an inclusion, a directory entry, a line of an outliner document — is read only for the feed it names, and everything else about it is dropped.
Nothing is fetched. A subscription's feed URL arrives exactly as the document wrote it, relative forms included; resolving or retrieving one is the caller's.