diff --git a/src/atom.ts b/src/atom.ts index 5e6d0da..0f5c621 100644 --- a/src/atom.ts +++ b/src/atom.ts @@ -34,9 +34,10 @@ export class AtomFeed extends BaseFeed { ` ${escapeXml(this.options.title)}\n`, ` ${escapeXml(this.options.description)}\n`, ` \n`, + ` ${escapeXml(this.options.id ?? this.options.link)}\n`, ` ${this.options.updated?.toISOString()}\n`, ` ${ - this.options.generator || "@feed/feed on JSR.io" + escapeXml(this.options.generator || "@feed/feed on JSR.io") }\n`, ]; @@ -75,8 +76,7 @@ export class AtomFeed extends BaseFeed { ` \n`, ` ${escapeXml(entry.id)}\n`, ` ${ - entry.updated?.toUTCString() || - new Date().toUTCString() + (entry.updated ?? new Date()).toISOString() }\n`, ` ${escapeXml(entry.summary)}\n`, ` ${ diff --git a/src/json.ts b/src/json.ts index e010e1c..fde4d02 100644 --- a/src/json.ts +++ b/src/json.ts @@ -29,17 +29,20 @@ export class JsonFeed extends BaseFeed { home_page_url: this.options.link, feed_url: this.options.feed, icon: this.options.icon, - updated: this.options.updated?.toISOString(), + date_modified: this.options.updated?.toISOString(), items: this.items.map(( { id, title, url, date_published, content_html }, ) => ({ id, title, url, - date_published: date_published?.toISOString() || new Date().toUTCString, + ...(date_published && { + date_published: date_published.toISOString(), + }), ...(content_html && { content_html }), })), }; + return JSON.stringify(json, null, 2); } } diff --git a/src/rss.ts b/src/rss.ts index 5d97345..89de644 100644 --- a/src/rss.ts +++ b/src/rss.ts @@ -30,7 +30,9 @@ export class RssFeed extends BaseFeed { build(): string { const xmlParts: string[] = [ `\n`, - `\n`, + `\n`, ` \n`, ` ${escapeXml(this.options.title)}\n`, ` ${escapeXml(this.options.description)}\n`, diff --git a/test.ts b/test.ts index c6265c1..94ca572 100644 --- a/test.ts +++ b/test.ts @@ -1,5 +1,12 @@ import { Atom, Json, Rss } from "@feed/feed"; +interface XmlNode { + name: string; + attributes: Record; + children: XmlNode[]; + text: string; +} + function assertEquals(actual: string, expected: string): void { if (actual !== expected) { const actualLines = actual.split("\n"); @@ -16,7 +23,138 @@ function assertEquals(actual: string, expected: string): void { } } -Deno.test("RSS Feed Generation", () => { +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +function parseXml(xml: string): Record { + const root: XmlNode = { + name: "__root__", + attributes: {}, + children: [], + text: "", + }; + const stack: XmlNode[] = [root]; + const tokenPattern = /<[^>]+>|[^<]+/g; + + for (const token of xml.match(tokenPattern) ?? []) { + if (token.startsWith("<")) { + if ( + token.startsWith("]+)\s*>$/); + if (closeMatch) { + const closing = closeMatch[1]; + const current = stack.pop(); + if (!current || current.name !== closing) { + throw new Error( + `XML parse error: unexpected closing tag `, + ); + } + continue; + } + + const selfClosing = /\/>\s*$/.test(token); + const openMatch = token.match(/^<\s*([^\s/>]+)([\s\S]*?)\/?\s*>$/); + if (!openMatch) { + throw new Error(`XML parse error: invalid tag ${token}`); + } + + const [, name, rawAttrs] = openMatch; + const node: XmlNode = { + name, + attributes: parseAttributes(rawAttrs), + children: [], + text: "", + }; + + stack[stack.length - 1].children.push(node); + if (!selfClosing) { + stack.push(node); + } + continue; + } + + stack[stack.length - 1].text += token; + } + + if (stack.length !== 1) { + const unclosed = stack[stack.length - 1].name; + throw new Error(`XML parse error: unclosed tag <${unclosed}>`); + } + + const result: Record = {}; + for (const child of root.children) { + result[child.name] = nodeToValue(child); + } + return result; +} + +function parseAttributes(raw: string): Record { + const attributes: Record = {}; + const attrPattern = /([:\w.-]+)\s*=\s*(["'])(.*?)\2/g; + for (const match of raw.matchAll(attrPattern)) { + attributes[match[1]] = match[3]; + } + return attributes; +} + +function nodeToValue(node: XmlNode): unknown { + const hasAttributes = Object.keys(node.attributes).length > 0; + const hasChildren = node.children.length > 0; + const text = node.text.trim(); + + if (!hasAttributes && !hasChildren) { + return text; + } + + const value: Record = {}; + for (const [key, attrValue] of Object.entries(node.attributes)) { + value[`@_${key}`] = attrValue; + } + + for (const child of node.children) { + const childValue = nodeToValue(child); + const existing = value[child.name]; + if (existing === undefined) { + value[child.name] = childValue; + } else if (Array.isArray(existing)) { + existing.push(childValue); + } else { + value[child.name] = [existing, childValue]; + } + } + + if (text) { + value["#text"] = text; + } + + return value; +} + +function asRecord(value: unknown, message: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(message); + } + return value as Record; +} + +function hasText(value: unknown): boolean { + return typeof value === "string" && value.length > 0; +} + +function isIsoDate(s: string | null | undefined): boolean { + if (!s) return false; + return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/.test(s); +} + +Deno.test("RSS Feed Generation (snapshot)", () => { const rssFeed = new Rss({ title: "RSS Feed Example", description: "A simple RSS feed example", @@ -46,7 +184,9 @@ Deno.test("RSS Feed Generation", () => { const expected = ` - + RSS Feed Example A simple RSS feed example @@ -72,7 +212,75 @@ Deno.test("RSS Feed Generation", () => { assertEquals(rssFeed.build().replace(/\s/g, ""), expected.replace(/\s/g, "")); }); -Deno.test("Atom Feed Generation", () => { +Deno.test("RSS Feed Generation (well-formed + basic checks)", () => { + const rssFeed = new Rss({ + title: "RSS Feed Example", + description: "A simple RSS feed example", + link: "http://example.com/rss-feed", + updated: new Date("2024-10-19T15:12:56Z"), + id: "http://example.com/rss-feed", + authors: [ + { + name: "John Doe", + email: "test@example.org", + }, + ], + }); + + rssFeed.addItem({ + title: "First RSS Item", + link: "http://example.com/rss1", + id: "http://example.com/rss1", + updated: new Date("2024-10-19T15:12:56Z"), + description: "Description for RSS item 1", + content: { + body: "Content for RSS item 1", + type: "html", + }, + image: "http://example.com/image.jpg", + }); + + const xml = rssFeed.build(); + const doc = parseXml(xml); + + const rss = asRecord(doc.rss, "Missing root element"); + assert(rss["@_version"] === "2.0", "RSS version must be 2.0"); + + // Only assert namespaces if the prefixed elements exist (less brittle) + const hasContentEncoded = xml.includes("", + ); + } + + const hasMediaThumb = xml.includes("", + ); + } + + const channel = asRecord(rss.channel, "Missing channel element"); + assert(hasText(channel.title), "Missing channel title"); + assert(hasText(channel.link), "Missing channel link"); + assert(hasText(channel.description), "Missing channel description"); + + const itemValue = Array.isArray(channel.item) + ? channel.item[0] + : channel.item; + const item = asRecord(itemValue, "Missing item element"); + assert(hasText(item.title), "Missing item title"); + assert(hasText(item.link), "Missing item link"); + assert(hasText(item.guid), "Missing item guid"); + assert(hasText(item.pubDate), "Missing item pubDate"); + assert(hasText(item.description), "Missing item description"); +}); + +Deno.test("Atom Feed Generation (snapshot)", () => { const atomFeed = new Atom({ title: "Atom Feed Example", description: "A simple Atom feed example", @@ -105,6 +313,7 @@ Deno.test("Atom Feed Generation", () => { Atom Feed Example A simple Atom feed example + https://example.com/atom-feed 2024-10-19T15:12:56.000Z @feed/feed on JSR.io @@ -115,7 +324,7 @@ Deno.test("Atom Feed Generation", () => { First Atom Item 1 - Sat, 19 Oct 2024 15:12:56 GMT + 2024-10-19T15:12:56.000Z Summary for Atom item 1 Content for Atom item 1 @@ -128,7 +337,66 @@ Deno.test("Atom Feed Generation", () => { ); }); -Deno.test("JSON Feed Generation", () => { +Deno.test("Atom Feed Generation (well-formed + basic checks)", () => { + const atomFeed = new Atom({ + title: "Atom Feed Example", + description: "A simple Atom feed example", + link: "http://example.com/atom-feed", + authors: [ + { + name: "John Doe", + link: "https://example.org", + }, + ], + updated: new Date("2024-10-19T15:12:56Z"), + id: "https://example.com/atom-feed", + }); + + atomFeed.addItem({ + title: "First Atom Item", + link: "http://example.com/atom1", + id: "1", + updated: new Date("2024-10-19T15:12:56Z"), + summary: "Summary for Atom item 1", + content: { + body: "Content for Atom item 1", + type: "html", + }, + }); + + const xml = atomFeed.build(); + const doc = parseXml(xml); + + const feed = asRecord(doc.feed, "Missing root element"); + + // Atom requirements (minimal): + assert(hasText(feed.title), "Missing "); + assert(hasText(feed.id), "Missing <feed><id>"); + assert(hasText(feed.updated), "Missing <feed><updated>"); + + const entryValue = Array.isArray(feed.entry) ? feed.entry[0] : feed.entry; + const entry = asRecord(entryValue, "Missing <entry>"); + assert(hasText(entry.id), "Missing <entry><id>"); + + const feedUpdated = typeof feed.updated === "string" ? feed.updated : null; + // If your implementation uses ISO, enforce it: + if (feedUpdated?.includes("T")) { + assert( + isIsoDate(feedUpdated), + `Feed <updated> should be ISO (got ${feedUpdated})`, + ); + } + + const entryUpdated = typeof entry.updated === "string" ? entry.updated : null; + if (entryUpdated?.includes("T")) { + assert( + isIsoDate(entryUpdated), + `Entry <updated> should be ISO (got ${entryUpdated})`, + ); + } +}); + +Deno.test("JSON Feed Generation (snapshot)", () => { const jsonFeed = new Json({ title: "JSON Feed Example", description: "A simple JSON feed example", @@ -157,7 +425,7 @@ Deno.test("JSON Feed Generation", () => { "title": "JSON Feed Example", "home_page_url": "http://example.com/json-feed", "feed_url": "http://example.com/json-feed/feed.json", - "updated": "2024-10-19T15:12:56.000Z", + "date_modified": "2024-10-19T15:12:56.000Z", "items": [ { "id": "1", @@ -175,3 +443,45 @@ Deno.test("JSON Feed Generation", () => { expected.replace(/\s/g, ""), ); }); + +Deno.test("JSON Feed Generation (parses + basic checks)", () => { + const jsonFeed = new Json({ + title: "JSON Feed Example", + description: "A simple JSON feed example", + link: "http://example.com/json-feed", + feed: "http://example.com/json-feed/feed.json", + authors: [{ name: "John Doe", email: "test@example.org" }], + updated: new Date("2024-10-19T15:12:56Z"), + }); + + jsonFeed.addItem({ + id: "1", + title: "First JSON Item", + url: "http://example.com/json1", + date_published: new Date("2024-10-19T15:12:56Z"), + content_html: "Content for JSON item 1", + }); + + const text = jsonFeed.build(); + const obj = JSON.parse(text) as Record<string, unknown>; + + assert(typeof obj.version === "string", "JSON Feed must have version"); + assert(typeof obj.title === "string", "JSON Feed must have title"); + assert( + typeof obj.home_page_url === "string", + "JSON Feed must have home_page_url", + ); + + const items = obj.items as Array<Record<string, unknown>>; + assert(Array.isArray(items), "JSON Feed must have items array"); + assert(items.length > 0, "JSON Feed must have at least one item"); + + const first = items[0]; + assert(typeof first.id === "string", "Item must have id"); + assert(typeof first.url === "string", "Item must have url"); + + const dp = first.date_published; + if (typeof dp === "string") { + assert(isIsoDate(dp), `date_published must be ISO string (got ${dp})`); + } +});