diff --git a/README.md b/README.md index f83910b..69a9e04 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,18 @@ # @jsonic/feed -A [Jsonic](https://jsonic.senecajs.org) plugin (built on -[`@jsonic/xml`](https://github.com/jsonicjs/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; pass `format: 'native'` to keep the source -dialect's structure, or `format: 'raw'` to get back the underlying -XML element tree from `@jsonic/xml`. +A [Jsonic](https://jsonic.senecajs.org) plugin — built on +[`@jsonic/xml`](https://github.com/jsonicjs/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 | -| ---------- | -------------------------------------------------------------- | ----------------------------------- | -| TypeScript | [`@jsonic/feed`](https://npmjs.com/package/@jsonic/feed) | [`src/feed.ts`](src/feed.ts) | -| Go | [`github.com/jsonicjs/feed/go`](https://github.com/jsonicjs/feed/tree/main/go) | [`go/feed.go`](go/feed.go) | +| Language | Package | Source | Docs | +| ---------- | -------------------------------------------------------------- | ---------------------------- | --------------------------------- | +| TypeScript | [`@jsonic/feed`](https://npmjs.com/package/@jsonic/feed) | [`src/feed.ts`](src/feed.ts) | this file | +| Go | [`github.com/jsonicjs/feed/go`](https://github.com/jsonicjs/feed/tree/main/go) | [`go/feed.go`](go/feed.go) | [`go/README.md`](go/README.md) | [![npm version](https://img.shields.io/npm/v/@jsonic/feed.svg)](https://npmjs.com/package/@jsonic/feed) [![build](https://github.com/jsonicjs/feed/actions/workflows/build.yml/badge.svg)](https://github.com/jsonicjs/feed/actions/workflows/build.yml) @@ -23,30 +22,45 @@ The same parser is available in two languages: | ---------------------------------------------------- | --------------------------------------------------------------------------------------- | -The documentation below is organised along the -[Diátaxis](https://diataxis.fr) quadrants: +> **Go users:** the [`go/README.md`](go/README.md) is a Go-only view +> of these same docs. The rest of this file covers both languages +> side by side. -- [Quick start](#quick-start) — tutorial -- [How-to guides](#how-to-guides) — task recipes -- [Reference](#reference) — API surface -- [Format mapping](#format-mapping) — explanation +This documentation follows the four [Diátaxis](https://diataxis.fr) +modes: +- [Tutorial](#tutorial) — work through a first feed parse +- [How-to guides](#how-to-guides) — short recipes for specific tasks +- [Reference](#reference) — types, options, mapping tables +- [Explanation](#explanation) — design rationale and trade-offs -## Quick start + +--- + +## Tutorial + +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. ### TypeScript +Install the plugin and its peer dependencies: + ```bash npm install @jsonic/feed jsonic @jsonic/xml ``` +Create `index.ts`: + ```typescript import { Jsonic } from 'jsonic' import { Feed } from '@jsonic/feed' const j = Jsonic.make().use(Feed) -const atom = j(` +const result = j(` My Blog @@ -62,17 +76,29 @@ const atom = j(` `) -// atom.format === 'atom' -// atom.title.value === 'My Blog' -// atom.entries[0].id === 'https://example.com/1' +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 ``, and the `` 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. + ### Go +Initialise a module and pull in the plugin: + ```bash +go mod init example go get github.com/jsonicjs/feed/go ``` +Create `main.go`: + ```go package main @@ -87,7 +113,8 @@ func main() { if err := j.UseDefaults(feed.Feed, feed.Defaults); err != nil { panic(err) } - got, err := j.Parse(` + got, err := j.Parse(` + My Blog Hello1 @@ -98,33 +125,37 @@ func main() { } 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). + + +--- ## How-to guides -### Keep the source dialect's structure (no Atom conversion) +### How to keep the source dialect's structure -TypeScript: +When you need RSS-specific fields like `ttl`, `cloud`, or `skipDays` +that the Atom shape does not carry, ask for the native form: ```typescript -import { Jsonic } from 'jsonic' import { Feed, type Rss2Feed } from '@jsonic/feed' - const j = Jsonic.make().use(Feed, { format: 'native' }) const native = j(rssSource) as Rss2Feed -// native.format === 'rss', native.version === '2.0' +// native.ttl, native.cloud, native.skipDays ``` -Go: - ```go j := jsonic.Make() j.UseDefaults(feed.Feed, feed.Defaults, map[string]any{"format": "native"}) got, _ := j.Parse(rssSource) native := got.(feed.Rss2Feed) -// native.Format == "rss", native.Version == "2.0" +// native.TTL, native.Cloud, native.SkipDays ``` The native return type is a discriminated union on `format`: @@ -135,9 +166,11 @@ The native return type is a discriminated union on `format`: | 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'` | -### Get the raw XML tree +### How to access the raw XML tree -TypeScript: +When even the native shape is not enough — for example you need a +non-standard namespace extension like `` — drop down +to the raw element tree from `@jsonic/xml`: ```typescript const j = Jsonic.make().use(Feed, { format: 'raw' }) @@ -145,22 +178,18 @@ const tree = j(rssSource) // tree.localName === 'rss', tree.children === [...] ``` -Go: - ```go 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) == [...] +// tree["localName"] == "rss"; tree["children"].([]any) ``` -This is the element tree produced by `@jsonic/xml` with no further -processing, useful when you want to handle non-standard extensions. +### How to detect a feed's dialect without converting -### Detect a dialect without converting - -TypeScript: +Use `format: 'raw'` to get the underlying XML tree, then call +`detect`: ```typescript import { Feed, detect } from '@jsonic/feed' @@ -169,74 +198,64 @@ const { dialect, version } = detect(j(rssSource)) // e.g. { dialect: 'rss', version: 'rss20' } ``` -Go: - ```go j := jsonic.Make() j.UseDefaults(feed.Feed, feed.Defaults, map[string]any{"format": "raw"}) got, _ := j.Parse(rssSource) det := feed.Detect(got) -// e.g. feed.Detection{Dialect: "rss", Version: "rss20"} +// e.g. {Dialect: "rss", Version: "rss20"} ``` +### How to read fields that the Atom conversion drops -## Reference +The default conversion is lossy by design (see +[Explanation](#what-conversion-loses) 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 -```typescript -const Feed: Plugin - -function detect(root: XmlElement): { dialect: FeedDialect; version: FeedVersion } +--- -type FeedOptions = { - format?: 'atom' | 'native' | 'raw' // default: 'atom' -} +## Reference -type FeedResult = AtomFeed | Rss2Feed | Rss1Feed | XmlElement +### Plugin registration -type FeedDialect = 'atom' | 'rss' | 'rdf' | 'unknown' +**TypeScript** -type FeedVersion = - | 'atom10' | 'atom03' - | 'rss20' | 'rss092' | 'rss091u' | 'rss091n' - | 'rss10' | 'rss090' - | 'unknown' +```typescript +import { Feed } from '@jsonic/feed' +const j = Jsonic.make().use(Feed, options?) +const result = j(src) ``` -Use the plugin via `Jsonic.make().use(Feed, options?)`. After -registration, invoke the jsonic instance as a function on a feed XML -source string; it returns the converted feed (or the raw `XmlElement` -tree, when `options.format === 'raw'`). - -| Option | Type | Default | Effect | -|----------|-------------------------------|----------|-------------------------------------------------------| -| `format` | `'atom' \| 'native' \| 'raw'` | `'atom'` | Output shape: normalised Atom, dialect-native, or raw XML tree | - -### Go +**Go** ```go -func Feed(j *jsonic.Jsonic, opts map[string]any) error -func Detect(root any) Detection +j := jsonic.Make() +err := j.UseDefaults(feed.Feed, feed.Defaults, opts) +result, err := j.Parse(src) +``` -var Defaults = map[string]any{ "format": "atom" } +### Options -type Detection struct { - Dialect string `json:"dialect"` - Version string `json:"version"` -} -``` +| Key | Type | Default | Effect | +|----------|-------------------------------|----------|-------------------------------------------------------| +| `format` | `'atom' \| 'native' \| 'raw'` | `'atom'` | Output shape: normalised Atom, dialect-native, or raw XML element tree | -Register with `j.UseDefaults(feed.Feed, feed.Defaults, opts)` where -`opts` is a `map[string]any` overriding the defaults. `Parse` then -returns `(any, error)`; type-assert the result to `feed.AtomFeed`, -`feed.Rss2Feed`, or `feed.Rss1Feed` based on the `format` option. +### Result types -| Key | Type | Default | Effect | -|----------|----------|----------|-------------------------------------------------------| -| `format` | `string` | `"atom"` | Output shape: `"atom"`, `"native"`, or `"raw"` | +The `format` option determines which type the parser returns: -Atom shape (the default output) follows RFC 4287 closely: +| `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` | + +### Atom shape (RFC 4287) ```typescript type AtomFeed = { @@ -281,79 +300,154 @@ type AtomGenerator = { uri?: string; version?: string; value: string } type AtomContent = { type: string; src?: string; value?: string } ``` -The Go structs (`AtomFeed`, `AtomEntry`, `Rss2Feed`, `Rss2Item`, -`Rss1Feed`, `Rss1Item`, …) carry equivalent JSON tags so they marshal -to the same shape. See [`go/feed.go`](go/feed.go) for the full set. - - -## Format mapping - -When converting any RSS dialect to Atom, the plugin makes the following -best-effort mappings: - -| 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 `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) | - -For RDF (RSS 1.0/0.90): - -| 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'`) | - -For Atom 0.3 → Atom 1.0 the legacy element names are renamed: -`tagline → subtitle`, `modified → updated`, `issued → published`, -`copyright → rights`. - - -## Tests - -The TypeScript and Go test suites share fixtures from -[`test/specs/`](test/specs/) — each base name has a `.xml` input and one -or more expected JSON outputs (`.atom.json`, `.native.json`, -`.detect.json`). Both languages enumerate the directory and JSON-compare -the parser result to the expected output, so adding a new fixture is -covered by both immediately. - -Both suites also run against a focused subset of well-formed feeds -vendored from [`kurtmckee/feedparser`](https://github.com/kurtmckee/feedparser) -under BSD 2-Clause (see -[THIRD_PARTY_NOTICES.md](./THIRD_PARTY_NOTICES.md)) at -[`test/feedparser-wellformed/`](test/feedparser-wellformed/). +The Go structs (`AtomFeed`, `AtomEntry`, …) carry equivalent JSON +tags so they marshal to the same shape. See +[`go/feed.go`](go/feed.go) for the full set, including the native +RSS 2.0 (`Rss2Feed`, `Rss2Item`, …) and RSS 1.0 (`Rss1Feed`, +`Rss1Item`, …) types. +### Detection helper -## Acknowledgments +```typescript +function detect(root: XmlElement): + { dialect: 'atom' | 'rss' | 'rdf' | 'unknown' + version: 'atom10' | 'atom03' | 'rss20' | 'rss092' | + 'rss091u' | 'rss091n' | 'rss10' | 'rss090' | + 'unknown' } +``` + +```go +func Detect(root any) Detection +type Detection struct { Dialect, Version string } +``` + +### Mapping: RSS 2.x / 0.92 / 0.91 → Atom + +| 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) | + +### Mapping: RDF (RSS 1.0 / 0.90) → Atom + +| 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 → 1.0 element renames + +| Atom 0.3 | Atom 1.0 | +|-------------|-------------| +| `tagline` | `subtitle` | +| `modified` | `updated` | +| `issued` | `published` | +| `copyright` | `rights` | + + +--- + +## Explanation + +### Why default to Atom? + +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'`. + +### What conversion loses + +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 becomes `entry.id` + but the boolean is dropped) +- `image/title`, `image/link`, `image/width`, `image/height` + (Atom's `logo` is just a URL) +- `textInput` (an obsolete RSS UI element with no Atom counterpart) +- `category/@domain` becomes `category.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. + +### Composition with `@jsonic/xml` + +The plugin layers on top of [`@jsonic/xml`](https://github.com/jsonicjs/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. -Conformance testing uses third-party corpora under permissive licenses -(see [THIRD_PARTY_NOTICES.md](./THIRD_PARTY_NOTICES.md) for full -attribution): +### Cross-language parity through shared fixtures + +The TypeScript and Go implementations are kept in sync through +[`test/specs/`](test/specs/): each `.xml` ships with a +`.detect.json`, `.atom.json`, and an optional +`.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`](https://github.com/kurtmckee/feedparser) +is also vendored at +[`test/feedparser-wellformed/`](test/feedparser-wellformed/) under +BSD 2-Clause; both languages run the same no-error and targeted +value checks against it. + + +--- + +## Acknowledgments - [kurtmckee/feedparser](https://github.com/kurtmckee/feedparser) by - Kurt McKee and Mark Pilgrim — a focused subset of well-formed feed - samples is vendored at `test/feedparser-wellformed/`. + Kurt McKee and Mark Pilgrim — vendored well-formed corpus, BSD + 2-Clause. See [THIRD_PARTY_NOTICES.md](./THIRD_PARTY_NOTICES.md). ## License diff --git a/go/README.md b/go/README.md new file mode 100644 index 0000000..1ff332d --- /dev/null +++ b/go/README.md @@ -0,0 +1,378 @@ +# feed (Go) + +A Go port of [`@jsonic/feed`](https://github.com/jsonicjs/feed) — a +[Jsonic](https://github.com/jsonicjs/jsonic) plugin (built on +[`xml`](https://github.com/jsonicjs/xml)) that parses syndication +feeds (**RSS 0.90, 0.91, 0.92, 1.0, 2.0** and **Atom 0.3, 1.0**) +into typed Go structs. By default every dialect is normalised to an +Atom-shaped result, so the same downstream code can consume feeds +from any source. + +For the full project — including the TypeScript implementation, +shared test fixtures, and language-agnostic explanation — see the +[main README](../README.md). + +This README follows the four [Diátaxis](https://diataxis.fr) modes: + +- [Tutorial](#tutorial) — work through a first feed parse +- [How-to guides](#how-to-guides) — short recipes for specific tasks +- [Reference](#reference) — types, options, mapping tables +- [Explanation](#explanation) — design rationale and trade-offs + + +--- + +## Tutorial + +This walkthrough takes you from an empty Go module to a parsed feed. + +Initialise a module and pull in the plugin: + +```bash +go mod init example +go get github.com/jsonicjs/feed/go +``` + +Create `main.go`: + +```go +package main + +import ( + "fmt" + + jsonic "github.com/jsonicjs/jsonic/go" + feed "github.com/jsonicjs/feed/go" +) + +func main() { + j := jsonic.Make() + if err := j.UseDefaults(feed.Feed, feed.Defaults); err != nil { + panic(err) + } + got, err := j.Parse(` + + + My Blog + https://example.com/ + Posts + + Hello + https://example.com/1 + https://example.com/1 + + + `) + if err != nil { + panic(err) + } + f := got.(feed.AtomFeed) + fmt.Println(f.Title.Value) // My Blog + fmt.Println(f.Entries[0].ID) // https://example.com/1 + fmt.Println(f.Entries[0].Links[0]) // {https://example.com/1 alternate ...} +} +``` + +The input was RSS 2.0 but `f` is an `AtomFeed`: `Title` is an +`*AtomText` (carrying its content type), the `` became +`Entries[0].ID`, and the `` became an `AtomLink` 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. + +`Parse` returns `(any, error)`. With the default `format`, the +concrete type is `feed.AtomFeed`; with `format: "native"` it is +`feed.Rss2Feed`, `feed.Rss1Feed`, or `feed.AtomFeed` depending on +the input dialect; with `format: "raw"` it is `map[string]any` (the +raw element tree from `@jsonic/xml`). + + +--- + +## How-to guides + +### How to keep the source dialect's structure + +When you need RSS-specific fields like `TTL`, `Cloud`, or +`SkipDays` that the Atom shape does not carry, ask for the native +form: + +```go +j := 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.SkipDays +``` + +The native return type depends on the input dialect: + +| Input dialect | Type assertion target | +|-----------------------|-----------------------| +| Atom 1.0 / Atom 0.3 | `feed.AtomFeed` | +| RSS 2.0 / 0.92 / 0.91 | `feed.Rss2Feed` | +| RSS 1.0 / 0.90 | `feed.Rss1Feed` | + +Switch on the `Format` field if the dialect is not known up front: + +```go +switch v := got.(type) { +case feed.AtomFeed: + // v.Format == "atom" +case feed.Rss2Feed: + // v.Format == "rss" +case feed.Rss1Feed: + // v.Format == "rdf" +} +``` + +### How to access the raw XML tree + +When even the native shape is not enough — for example you need a +non-standard namespace extension like `` — drop down +to the raw element tree from `@jsonic/xml`: + +```go +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) == [...] +// tree["attributes"].(map[string]string) == {"version": "2.0"} +``` + +### How to detect a feed's dialect without converting + +Use `format: "raw"` to get the underlying XML tree, then call +`feed.Detect`: + +```go +j := jsonic.Make() +j.UseDefaults(feed.Feed, feed.Defaults, map[string]any{"format": "raw"}) +got, _ := j.Parse(rssSource) +det := feed.Detect(got) +// e.g. feed.Detection{Dialect: "rss", Version: "rss20"} +``` + +### How to handle parse errors + +`Parse` returns a `*jsonic.JsonicError` for both XML-level errors +(unterminated tag, mismatched close, etc.) and feed-level errors +(unrecognised root element): + +```go +got, err := j.Parse(src) +if err != nil { + var je *jsonic.JsonicError + if errors.As(err, &je) { + // structured error with row/col, error code, source snippet + log.Printf("feed parse failed: %v", je) + } + return err +} +``` + +### How to read fields that the Atom conversion drops + +The default conversion is lossy by design (see +[Explanation](#what-conversion-loses) below). The recommended path +is to register the plugin with `format: "native"` and convert in +your own code only when you need the Atom shape. + + +--- + +## Reference + +### Plugin registration + +```go +func Feed(j *jsonic.Jsonic, opts map[string]any) error +var Defaults = map[string]any{ "format": "atom" } +``` + +Register via `UseDefaults`: + +```go +j := jsonic.Make() +err := j.UseDefaults(feed.Feed, feed.Defaults, opts) +result, err := j.Parse(src) +``` + +`UseDefaults` merges `opts` over `Defaults`, so caller options +override defaults. Pass `nil` (or omit) for defaults-only. + +### Options + +| Key | Type | Default | Effect | +|----------|----------|----------|-------------------------------------------------| +| `format` | `string` | `"atom"` | `"atom"`, `"native"`, or `"raw"` (see below) | + +### Result types by `format` + +| `format` | Concrete type returned by `Parse` | +|------------|--------------------------------------------------------| +| `"atom"` | `feed.AtomFeed` | +| `"native"` | `feed.AtomFeed` / `feed.Rss2Feed` / `feed.Rss1Feed` | +| `"raw"` | `map[string]any` (the `@jsonic/xml` element tree) | + +### Atom shape (RFC 4287) + +```go +type AtomFeed struct { + Format string `json:"format"` + Version string `json:"version"` + ID string `json:"id,omitempty"` + Title *AtomText `json:"title,omitempty"` + Updated string `json:"updated,omitempty"` + Authors []AtomPerson `json:"authors,omitempty"` + Contributors []AtomPerson `json:"contributors,omitempty"` + Categories []AtomCategory `json:"categories,omitempty"` + Generator *AtomGenerator `json:"generator,omitempty"` + Icon string `json:"icon,omitempty"` + Logo string `json:"logo,omitempty"` + Rights *AtomText `json:"rights,omitempty"` + Subtitle *AtomText `json:"subtitle,omitempty"` + Links []AtomLink `json:"links,omitempty"` + Entries []AtomEntry `json:"entries"` +} + +type AtomEntry struct { + ID string `json:"id,omitempty"` + Title *AtomText `json:"title,omitempty"` + Updated string `json:"updated,omitempty"` + Published string `json:"published,omitempty"` + Authors []AtomPerson `json:"authors,omitempty"` + Contributors []AtomPerson `json:"contributors,omitempty"` + Categories []AtomCategory `json:"categories,omitempty"` + Content *AtomContent `json:"content,omitempty"` + Links []AtomLink `json:"links,omitempty"` + Rights *AtomText `json:"rights,omitempty"` + Summary *AtomText `json:"summary,omitempty"` + Source *AtomEntrySource `json:"source,omitempty"` +} + +type AtomText struct { Type, Value string } +type AtomPerson struct { Name, URI, Email string } +type AtomLink struct { Href, Rel, Type, Hreflang, Title string; Length int } +type AtomCategory struct { Term, Scheme, Label string } +type AtomGenerator struct { URI, Version, Value string } +type AtomContent struct { Type, Src, Value string } +``` + +`AtomEntrySource` is a slim variant of `AtomFeed` (no `Entries`) +used for the Atom-source-element that an RSS `/` maps +to. + +### Native types + +```go +type Rss2Feed struct { /* RSS 0.91 / 0.92 / 2.0 channel + items */ } +type Rss2Item struct { /* one in RSS 2.x */ } +type Rss1Feed struct { /* RSS 0.90 / 1.0 RDF channel + items */ } +type Rss1Item struct { /* one in RSS 1.0 */ } +``` + +See [`feed.go`](feed.go) for the full field list. + +### Detection helper + +```go +func Detect(root any) Detection +type Detection struct { + Dialect string `json:"dialect"` + Version string `json:"version"` +} +``` + +`Dialect` is one of `"atom"`, `"rss"`, `"rdf"`, `"unknown"`. +`Version` is one of `"atom10"`, `"atom03"`, `"rss20"`, `"rss092"`, +`"rss091u"`, `"rss091n"`, `"rss10"`, `"rss090"`, `"unknown"`. + +### Mapping tables + +For the full RSS-to-Atom and RDF-to-Atom mapping tables, see the +[Reference section of the main README](../README.md#reference). + + +--- + +## Explanation + +### Why default to Atom? + +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. 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. + +### What conversion loses + +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` (the value becomes `Entries[i].ID` but the + boolean is dropped) +- `Image.Title`, `Image.Link`, `Image.Width`, `Image.Height` + (Atom's `Logo` is just a URL) +- `TextInput` (an obsolete RSS UI element with no Atom counterpart) + +If any of these matter, parse with `format: "native"` and read the +dialect-specific structure directly. + +### Composition with the `xml` plugin + +The plugin layers on top of +[`github.com/jsonicjs/xml/go`](https://github.com/jsonicjs/xml) in +three tiers: + +``` +src ──► xml plugin ───► native parser ───► Atom converter + (map[string]any) (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, `Feed` calls +`j.UseDefaults(xml.Xml, xml.Defaults)` itself and registers a +`bc` (before-close) action on the `xml` rule that runs after the +xml plugin'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. + +### JSON-shape parity with the TypeScript implementation + +The Go structs carry JSON tags chosen so that +`json.Marshal(result)` produces the same shape the TypeScript +parser produces with `JSON.stringify`. This is what makes the +shared fixtures in +[`../test/specs/`](../test/specs/) work for both languages: each +test JSON-marshal-unmarshals the parser output and deep-compares it +to the language-agnostic expected `*.atom.json` / `*.native.json`. + + +--- + +## Testing + +```bash +go test ./... +``` + +The Go test suite runs the cross-language fixtures in +[`../test/specs/`](../test/specs/) and the vendored well-formed +corpus in +[`../test/feedparser-wellformed/`](../test/feedparser-wellformed/). + + +--- + +## License + +MIT. Copyright (c) 2021-2025 Richard Rodger and contributors.