A Jsonic plugin — built on
@jsonic/xml — that parses
syndication feeds (RSS 0.90, 0.91, 0.92, 1.0, 2.0 and Atom 0.3,
1.0) into a typed structure. By default every dialect is normalised
to an Atom-shaped result, so the same downstream code can consume
feeds from any source.
The same parser is available in two languages:
| Language | Package | Source | Docs |
|---|---|---|---|
| TypeScript | @jsonic/feed | src/feed.ts | this file |
| Go | github.com/jsonicjs/feed/go | go/feed.go | go/README.md |
![]() | This open source module is sponsored and supported by Voxgig. |
|---|
Go users: the
go/README.mdis a Go-only view of these same docs. The rest of this file covers both languages side by side.
This documentation follows the four Diátaxis modes:
- Tutorial — work through a first feed parse
- How-to guides — short recipes for specific tasks
- Reference — types, options, mapping tables
- Explanation — design rationale and trade-offs
This walkthrough takes you from an empty project to a parsed feed in
under a minute. By the end you will have a working Feed-equipped
parser, recognise the shape of its output, and know where to look in
the rest of the docs.
Install the plugin and its peer dependencies:
npm install @jsonic/feed jsonic @jsonic/xmlCreate index.ts:
import{Jsonic}from'jsonic'import{Feed}from'@jsonic/feed'constj=Jsonic.make().use(Feed)constresult=j(` <rss version="2.0"> <channel> <title>My Blog</title> <link>https://example.com/</link> <description>Posts</description> <item> <title>Hello</title> <link>https://example.com/1</link> <guid>https://example.com/1</guid> <pubDate>Wed, 13 Dec 2003 18:30:02 GMT</pubDate> </item> </channel> </rss>`)console.log(result.title.value)// 'My Blog'console.log(result.entries[0].id)// 'https://example.com/1'console.log(result.entries[0].links[0])// { href: '...', rel: 'alternate' }The input was RSS 2.0 but result is in Atom shape:
title is an AtomText ({ type, value }), entries[0].id came
from RSS's <guid>, and the <link> became an Atom link with
rel: 'alternate'. The plugin handles every supported dialect this
way, so the rest of your code never has to branch on the source
format.
Initialise a module and pull in the plugin:
go mod init example
go get github.com/jsonicjs/feed/goCreate main.go:
package main
import (
"fmt"
jsonic "github.com/jsonicjs/jsonic/go"
feed "github.com/jsonicjs/feed/go"
)
funcmain() {
j:=jsonic.Make()
iferr:=j.UseDefaults(feed.Feed, feed.Defaults); err!=nil {
panic(err)
}
got, err:=j.Parse(` <rss version="2.0"> <channel> <title>My Blog</title> <item><title>Hello</title><guid>1</guid></item> </channel> </rss>`)
iferr!=nil {
panic(err)
}
f:=got.(feed.AtomFeed)
fmt.Println(f.Title.Value, "/", f.Entries[0].ID)
// My Blog / 1
}got is any; type-assert it to feed.AtomFeed (the default), or
to feed.Rss2Feed / feed.Rss1Feed when you opt into the native
shape (see the next section).
When you need RSS-specific fields like ttl, cloud, or skipDays
that the Atom shape does not carry, ask for the native form:
import{Feed,typeRss2Feed}from'@jsonic/feed'constj=Jsonic.make().use(Feed,{format: 'native'})constnative=j(rssSource)asRss2Feed// native.ttl, native.cloud, native.skipDaysj:=jsonic.Make()
j.UseDefaults(feed.Feed, feed.Defaults, map[string]any{"format": "native"})
got, _:=j.Parse(rssSource)
native:=got.(feed.Rss2Feed)
// native.TTL, native.Cloud, native.SkipDaysThe native return type is a discriminated union on format:
| Input dialect | Native return type | format | version |
|---|---|---|---|
| Atom 1.0 / Atom 0.3 | AtomFeed | 'atom' | '1.0' / '0.3' |
| RSS 2.0 / 0.92 / 0.91 | Rss2Feed | 'rss' | '2.0' / '0.92' / '0.91' |
| RSS 1.0 / 0.90 | Rss1Feed | 'rdf' | '1.0' / '0.90' |
When even the native shape is not enough — for example you need a
non-standard namespace extension like <media:content> — drop down
to the raw element tree from @jsonic/xml:
constj=Jsonic.make().use(Feed,{format: 'raw'})consttree=j(rssSource)// tree.localName === 'rss', tree.children === [...]j:=jsonic.Make()
j.UseDefaults(feed.Feed, feed.Defaults, map[string]any{"format": "raw"})
got, _:=j.Parse(rssSource)
tree:=got.(map[string]any)
// tree["localName"] == "rss"; tree["children"].([]any)Use format: 'raw' to get the underlying XML tree, then call
detect:
import{Feed,detect}from'@jsonic/feed'constj=Jsonic.make().use(Feed,{format: 'raw'})const{ dialect, version }=detect(j(rssSource))// e.g. { dialect: 'rss', version: 'rss20' }j:=jsonic.Make()
j.UseDefaults(feed.Feed, feed.Defaults, map[string]any{"format": "raw"})
got, _:=j.Parse(rssSource)
det:=feed.Detect(got)
// e.g. {Dialect: "rss", Version: "rss20"}The default conversion is lossy by design (see
Explanation below). If your application
needs both the convenient Atom shape and a stray RSS-only field,
parse twice into the same source — once for each format — or use
format: 'raw' plus your own extraction. The recommended path is to
parse 'native' and convert in your own code only when you need the
Atom shape.
TypeScript
import{Feed}from'@jsonic/feed'constj=Jsonic.make().use(Feed,options?)constresult=j(src)Go
j:=jsonic.Make()
err:=j.UseDefaults(feed.Feed, feed.Defaults, opts)
result, err:=j.Parse(src)| Key | Type | Default | Effect |
|---|---|---|---|
format | 'atom' | 'native' | 'raw' | 'atom' | Output shape: normalised Atom, dialect-native, or raw XML element tree |
The format option determines which type the parser returns:
format | TypeScript | Go |
|---|---|---|
'atom' | AtomFeed | feed.AtomFeed |
'native' | AtomFeed | Rss2Feed | Rss1Feed | feed.AtomFeed / feed.Rss2Feed / feed.Rss1Feed |
'raw' | XmlElement (from @jsonic/xml) | map[string]any |
typeAtomFeed={format: 'atom'version: '1.0'|'0.3'|stringid?: stringtitle?: AtomTextupdated?: stringauthors?: AtomPerson[]contributors?: AtomPerson[]categories?: AtomCategory[]generator?: AtomGeneratoricon?: stringlogo?: stringrights?: AtomTextsubtitle?: AtomTextlinks?: AtomLink[]entries: AtomEntry[]}typeAtomEntry={id?: stringtitle?: AtomTextupdated?: stringpublished?: stringauthors?: AtomPerson[]contributors?: AtomPerson[]categories?: AtomCategory[]content?: AtomContentlinks?: AtomLink[]rights?: AtomTextsummary?: AtomTextsource?: Partial<AtomFeed>}typeAtomText={type: 'text'|'html'|'xhtml';value: string}typeAtomPerson={name: string;uri?: string;email?: string}typeAtomLink={href: string;rel?: string;type?: string;hreflang?: string;title?: string;length?: number}typeAtomCategory={term: string;scheme?: string;label?: string}typeAtomGenerator={uri?: string;version?: string;value: string}typeAtomContent={type: string;src?: string;value?: string}The Go structs (AtomFeed, AtomEntry, …) carry equivalent JSON
tags so they marshal to the same shape. See
go/feed.go for the full set, including the native
RSS 2.0 (Rss2Feed, Rss2Item, …) and RSS 1.0 (Rss1Feed,
Rss1Item, …) types.
functiondetect(root: XmlElement):
{dialect: 'atom'|'rss'|'rdf'|'unknown'version: 'atom10'|'atom03'|'rss20'|'rss092'|'rss091u'|'rss091n'|'rss10'|'rss090'|'unknown'}funcDetect(rootany) DetectiontypeDetectionstruct { Dialect, Versionstring }| RSS source | Atom target |
|---|---|
channel/title | feed.title (type: 'text') |
channel/description | feed.subtitle (type: 'text') |
channel/link | feed.id and feed.links[] (rel: 'alternate') |
channel/copyright | feed.rights |
channel/lastBuildDate | feed.updated |
channel/pubDate | feed.updated (fallback) |
channel/managingEditor | feed.authors[0] (parsed as email (Name)) |
channel/generator | feed.generator.value |
channel/image/url | feed.logo |
item/guid | entry.id |
item/link (no guid) | entry.id (fallback) and entry.links[] |
item/description | entry.summary (type: 'html') |
item/pubDate | entry.published and entry.updated |
item/author | entry.authors[0] |
item/enclosure | entry.links[] with rel: 'enclosure' |
item/comments | entry.links[] with rel: 'replies' |
item/category | entry.categories[].term (+ scheme from domain) |
| RDF source | Atom target |
|---|---|
channel/@rdf:about | feed.id |
channel/title | feed.title |
channel/description | feed.subtitle |
channel/link | feed.links[] (rel: 'alternate') |
image/url | feed.logo |
item/@rdf:about | entry.id |
item/title | entry.title |
item/link | entry.links[] (rel: 'alternate') |
| Atom 0.3 | Atom 1.0 |
|---|---|
tagline | subtitle |
modified | updated |
issued | published |
copyright | rights |
Atom 1.0 (RFC 4287) is a strict superset of what every flavour of
RSS expresses, with consistent typed elements (AtomText carries
its content type, AtomLink carries rel/type/length, dates
are well-defined). RSS, by contrast, is a small family of related
formats with overlapping but inconsistent shapes — RSS 0.91 has no
guid, 0.92 added enclosure and category, 1.0 is RDF, 2.0
added cloud and ttl. Picking one shape for downstream code to
target avoids per-dialect branching, and Atom is the obvious
candidate because it can carry everything the others express.
The result is that 95% of feed-consuming code can ignore the source
dialect entirely. The remaining 5% — applications that genuinely
need RSS-only metadata — opt into format: 'native'.
Mapping RSS to Atom is not bijective. The default conversion drops:
ttl,cloud,skipHours,skipDays(RSS 2.x channel-level scheduling hints — Atom has no equivalent)guid/@isPermaLink(true/false flag; the value becomesentry.idbut the boolean is dropped)image/title,image/link,image/width,image/height(Atom'slogois just a URL)textInput(an obsolete RSS UI element with no Atom counterpart)category/@domainbecomescategory.scheme, which is the intended mapping but loses the original RSS naming
If any of these matter, parse with format: 'native' and read the
dialect-specific structure directly.
The plugin layers on top of @jsonic/xml
in three tiers:
src ──► @jsonic/xml ──► native parser ──► Atom converter
(XmlElement) (Rss2Feed/...) (AtomFeed)
format:'raw' format:'native' format:'atom' (default)
Each tier is exposed by a format option, so you can stop at
whichever level your application needs. Internally, the Feed plugin
calls jsonic.use(Xml) itself and registers a bc (before-close)
hook on the xml rule that runs after @jsonic/xml's own
@xml-bc. The hook gates on r.child.node — the same idiom
@xml-bc uses — so it runs exactly once even when the grammar's
trailing-whitespace recursion fires bc again.
The TypeScript and Go implementations are kept in sync through
test/specs/: each <name>.xml ships with a
<name>.detect.json, <name>.atom.json, and an optional
<name>.native.json. Both test suites enumerate the directory,
parse each input, and JSON-deep-compare the result against the
expectation after a marshal/unmarshal round-trip (which collapses
property-ordering and pointer-vs-value differences). Adding a
fixture covers both languages immediately.
A subset of the well-formed feed corpus from
kurtmckee/feedparser
is also vendored at
test/feedparser-wellformed/ under
BSD 2-Clause; both languages run the same no-error and targeted
value checks against it.
- kurtmckee/feedparser by Kurt McKee and Mark Pilgrim — vendored well-formed corpus, BSD 2-Clause. See THIRD_PARTY_NOTICES.md.
MIT. Copyright (c) 2021-2025 Richard Rodger and contributors.
