Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/formats/atom/atom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,21 @@ describe('toAtom', () => {
expect(entry).not.toContain('<link')
})

it('renders multiple feed-level authors as one <author> element each (RFC 4287 §4.1.1)', () => {
const out = toAtom({
options: {
title: 't',
link: 'https://example.com/',
author: [{ name: 'one' }, { name: 'two', url: 'https://example.com/two' }],
},
items: [],
})
const feedLevel = out.split('<entry')[0]
expect(feedLevel.match(/<author>/g)).toHaveLength(2)
expect(feedLevel).toContain('<name>one</name>')
expect(feedLevel).toContain('<uri>https://example.com/two</uri>')
})

it('renders per-item authors as <author> elements', () => {
const out = toAtom({
options: { title: 't', link: 'https://example.com/' },
Expand Down
2 changes: 1 addition & 1 deletion src/formats/atom/atom03.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export function toAtom03(input: FeedInput, opts: SerializeOptions): string {
if (options.description) feed.push(el('tagline', undefined, options.description))
if (link) feed.push(el('link', { rel: 'alternate', type: 'text/html', href: link }))
feed.push(el('modified', undefined, rfc3339(options.updated ?? latestDate(items) ?? new Date())))
if (options.author) feed.push(atomAuthorEl(options.author, 'url'))
for (const a of authorList(options.author)) feed.push(atomAuthorEl(a, 'url'))
feed.push(el('generator', undefined, options.generator ?? 'hono-feed'))
if (options.copyright) feed.push(el('copyright', undefined, options.copyright))
feed.push(el('id', undefined, feedId))
Expand Down
2 changes: 1 addition & 1 deletion src/formats/atom/atom10.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export function toAtom10(input: FeedInput, opts: SerializeOptions): string {
const marker = pagingMarker(options.paging)
if (marker) feed.push(el(`fh:${marker}`))
}
if (options.author) feed.push(atomAuthorEl(options.author, 'uri'))
for (const a of authorList(options.author)) feed.push(atomAuthorEl(a, 'uri'))
if (options.contributors) {
for (const c of options.contributors) feed.push(atomAuthorEl(c, 'uri', 'contributor'))
}
Expand Down
7 changes: 4 additions & 3 deletions src/formats/json/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,10 @@ export function toJSONFeed(input: FeedInput, opts: SerializeOptions = {}): strin
if (options.favicon) feed.favicon = absolutize(options.favicon, base)
// JSON Feed only has next_url; there's no equivalent for prev/first/last.
if (options.paging?.next) feed.next_url = absolutize(options.paging.next, base)
if (options.author) {
if (v1) feed.author = jsonAuthor(options.author, base)
else feed.authors = [jsonAuthor(options.author, base)]
const feedAuthors = authorList(options.author)
if (feedAuthors.length) {
if (v1) feed.author = jsonAuthor(feedAuthors[0], base)
else feed.authors = feedAuthors.map((a) => jsonAuthor(a, base))
}
const hubs = hubList(options.hub)
if (hubs.length) {
Expand Down
33 changes: 33 additions & 0 deletions src/formats/json/json.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,39 @@ describe('toJSONFeed', () => {
])
})

it('maps multiple feed-level authors to the 1.1 "authors" array', () => {
const json = JSON.parse(
toJSONFeed({
options: {
title: 't',
link: 'https://example.com/',
author: [{ name: 'one' }, { name: 'two', url: 'https://example.com/two' }],
},
items: [],
}),
)
expect(json.authors).toEqual([{ name: 'one' }, { name: 'two', url: 'https://example.com/two' }])
expect(json.author).toBeUndefined()
})

it('collapses multiple feed-level authors to the first for jsonFeedVersion 1', () => {
const json = JSON.parse(
toJSONFeed(
{
options: {
title: 't',
link: 'https://example.com/',
author: [{ name: 'one' }, { name: 'two' }],
},
items: [],
},
{ jsonFeedVersion: '1', suppressDeprecationWarnings: true },
),
)
expect(json.author).toEqual({ name: 'one' })
expect(json.authors).toBeUndefined()
})

it('maps multiple attachments from an enclosure array; RSS-style single object still works', () => {
const json = JSON.parse(
toJSONFeed({
Expand Down
7 changes: 5 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,11 @@ export interface FeedOptions {
updated?: Date
/** RSS channel `<pubDate>` (0.91+). No Atom/JSON feed-level equivalent (Atom has no feed-level published). */
published?: Date
/** RSS managingEditor (email required) / Atom author / JSON authors. */
author?: Author
/**
* RSS managingEditor (email required, collapses to the first author) / Atom author
* (RFC 4287 §4.1.1 allows more than one) / JSON Feed authors.
*/
author?: Author | Author[]
/** Atom `<contributor>` (RFC 4287 §4.2.3). No RSS/JSON equivalent. */
contributors?: Author[]
copyright?: string
Expand Down
5 changes: 5 additions & 0 deletions src/utils/author.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,8 @@ export function authorList(author: Author | Author[] | undefined): Author[] {
if (!author) return []
return Array.isArray(author) ? author : [author]
}

/** Whether an author value is actually present — an empty array doesn't count. */
export function hasAuthor(author: Author | Author[] | undefined): boolean {
return Array.isArray(author) ? author.length > 0 : author !== undefined
}
11 changes: 11 additions & 0 deletions src/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,17 @@ describe('validateInput', () => {
items: [{ ...noAuthor.items[0], author: [{ name: 'a' }] }],
}
expect(() => validateInput(nonEmptyAuthorArray, 'atom')).not.toThrow()

// A feed-level author array satisfies the rule too, but an empty one doesn't count —
// matching the item-level array checks above.
const feedAuthorArray = {
...noAuthor,
options: { ...noAuthor.options, author: [{ name: 'a' }] },
}
expect(() => validateInput(feedAuthorArray, 'atom')).not.toThrow()

const emptyFeedAuthorArray = { ...noAuthor, options: { ...noAuthor.options, author: [] } }
expect(() => validateInput(emptyFeedAuthorArray, 'atom')).toThrow(/requires an "author"/)
})

it('requires Atom ids to be absolute IRIs (RFC 4287 §4.2.6)', () => {
Expand Down
11 changes: 4 additions & 7 deletions src/validate.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { FeedFormat, FeedInput, FeedItem } from './types'
import type { FeedFormat, FeedInput } from './types'
import { hasAuthor } from './utils/author'
import { hasIriScheme } from './utils/url'

/**
Expand Down Expand Up @@ -50,8 +51,8 @@ export function validateInput(input: FeedInput, format: FeedFormat): void {
throw new TypeError('hono-feed: Atom feed requires "updated" when it has no items')
}
// RFC 4287 §4.1.1: the feed needs an author unless every entry carries one.
if (!options.author) {
const missing = items.findIndex((item) => !hasAuthor(item))
if (!hasAuthor(options.author)) {
const missing = items.findIndex((item) => !hasAuthor(item.author))
if (missing !== -1) {
throw new TypeError(
`hono-feed: Atom requires an "author" on the feed or on every item (item[${missing}] has none)`,
Expand Down Expand Up @@ -103,10 +104,6 @@ export function validateInput(input: FeedInput, format: FeedFormat): void {
})
}

function hasAuthor(item: FeedItem): boolean {
return Array.isArray(item.author) ? item.author.length > 0 : item.author !== undefined
}

function assertValidDate(d: Date | undefined, label: string): void {
if (d === undefined) return
if (!(d instanceof Date) || Number.isNaN(d.getTime())) {
Expand Down
Loading