sdxc

Type to search, or start from one of these:

@sdxc/opml

Read and write OPML subscription lists

npm add @sdxc/opml
pnpm add @sdxc/opml
yarn add @sdxc/opml
bun add @sdxc/opml
Used by
reader

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 xmlUrl is a folder: it is skipped, and the feeds under it are not.

  • folder is 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, then title, then htmlUrl, then xmlUrl, so every subscription carries something a row can be labelled with.

  • siteUrl comes from htmlUrl, 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

  1. 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 200 in an export's place reports itself rather than reading as a list with nothing in it.

  2. 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.

  3. The title is written twice, to text and title. The specification requires text; readers exist that label a row from title alone, and one value fills both.

  4. 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.

  5. 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.