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())) {