From 48de8fedf520d25f2d20361b261a6ab3bb7c63e1 Mon Sep 17 00:00:00 2001 From: "otoneko." Date: Tue, 14 Jul 2026 22:32:59 +0900 Subject: [PATCH] feat: allow FeedOptions.author to be Author | Author[] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The neutral model capped the feed at a single author even though two of the three formats allow more: RFC 4287 §4.1.1 permits multiple feed-level atom:author elements, and JSON Feed 1.1's authors is an array by definition (we always emitted a one-element array). RSS still collapses to the first author via firstAuthor(), same as it already does for item-level authors. Atom 1.0/0.3 now emit one per entry via authorList(), and JSON Feed maps the full list to authors (or the first to the singular author for jsonFeedVersion 1). validateInput's "feed needs an author unless every item has one" rule now treats an empty author array the same as no author, matching the existing item-level check — both now share utils/author.ts's hasAuthor(). Backward-compatible: a plain Author keeps working everywhere. --- src/formats/atom/atom.test.ts | 15 +++++++++++++++ src/formats/atom/atom03.ts | 2 +- src/formats/atom/atom10.ts | 2 +- src/formats/json/index.ts | 7 ++++--- src/formats/json/json.test.ts | 33 +++++++++++++++++++++++++++++++++ src/types.ts | 7 +++++-- src/utils/author.ts | 5 +++++ src/validate.test.ts | 11 +++++++++++ src/validate.ts | 11 ++++------- 9 files changed, 79 insertions(+), 14 deletions(-) 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())) {