diff --git a/src/formats/atom/atom.test.ts b/src/formats/atom/atom.test.ts index bcbf8d8..c88561e 100644 --- a/src/formats/atom/atom.test.ts +++ b/src/formats/atom/atom.test.ts @@ -188,6 +188,21 @@ describe('toAtom', () => { expect(entry).not.toContain(' 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('/g)).toHaveLength(2) + expect(feedLevel).toContain('one') + expect(feedLevel).toContain('https://example.com/two') + }) + it('renders per-item authors as elements', () => { const out = toAtom({ options: { title: 't', link: 'https://example.com/' }, diff --git a/src/formats/atom/atom03.ts b/src/formats/atom/atom03.ts index 18b553a..47196df 100644 --- a/src/formats/atom/atom03.ts +++ b/src/formats/atom/atom03.ts @@ -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)) diff --git a/src/formats/atom/atom10.ts b/src/formats/atom/atom10.ts index 1832c45..aa5dc01 100644 --- a/src/formats/atom/atom10.ts +++ b/src/formats/atom/atom10.ts @@ -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')) } diff --git a/src/formats/json/index.ts b/src/formats/json/index.ts index 28c794a..da51891 100644 --- a/src/formats/json/index.ts +++ b/src/formats/json/index.ts @@ -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) { diff --git a/src/formats/json/json.test.ts b/src/formats/json/json.test.ts index 70a7bc0..5d47e81 100644 --- a/src/formats/json/json.test.ts +++ b/src/formats/json/json.test.ts @@ -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({ diff --git a/src/types.ts b/src/types.ts index 820e0f5..2990519 100644 --- a/src/types.ts +++ b/src/types.ts @@ -170,8 +170,11 @@ export interface FeedOptions { updated?: Date /** RSS channel `` (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 `` (RFC 4287 §4.2.3). No RSS/JSON equivalent. */ contributors?: Author[] copyright?: string diff --git a/src/utils/author.ts b/src/utils/author.ts index af68a01..6833510 100644 --- a/src/utils/author.ts +++ b/src/utils/author.ts @@ -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 +} diff --git a/src/validate.test.ts b/src/validate.test.ts index 3093530..426114c 100644 --- a/src/validate.test.ts +++ b/src/validate.test.ts @@ -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)', () => { diff --git a/src/validate.ts b/src/validate.ts index 675f178..e6b7f99 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -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' /** @@ -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)`, @@ -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())) {