Skip to content

Commit 48e5b3b

Browse files
authored
bug: fix handling of xml elements with attributes (#26)
1 parent f133da8 commit 48e5b3b

4 files changed

Lines changed: 76 additions & 17 deletions

File tree

next-env.d.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
/// <reference types="next" />
22
/// <reference types="next/image-types/global" />
3-
import "./.next/types/routes.d.ts";
43

54
// NOTE: This file should not be edited
65
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

src/lib/rss.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,50 @@ describe("rss - XML to final output", () => {
162162
expect(normalizedRss).toMatchSnapshot();
163163
});
164164

165+
it("should handle guid with isPermaLink attribute (object instead of string)", async () => {
166+
// Some feeds have <guid isPermaLink="false">...</guid> which rss-parser
167+
// returns as an object like { $: { isPermaLink: "false" }, _: "actual-guid" }
168+
// or just { $: { isPermaLink: "false" } } if malformed
169+
const feedXml = `<?xml version="1.0" encoding="UTF-8"?>
170+
<rss version="2.0">
171+
<channel>
172+
<title>Test Feed</title>
173+
<link>http://example.com</link>
174+
<item>
175+
<title>Article with guid attribute</title>
176+
<link>http://example.com/article1</link>
177+
<guid isPermaLink="false">https://example.com/?p=12345</guid>
178+
<pubDate>Mon, 28 Oct 2025 10:00:00 GMT</pubDate>
179+
</item>
180+
<item>
181+
<title>Article with normal guid</title>
182+
<link>http://example.com/article2</link>
183+
<guid>http://example.com/article2</guid>
184+
<pubDate>Sun, 27 Oct 2025 10:00:00 GMT</pubDate>
185+
</item>
186+
</channel>
187+
</rss>`;
188+
189+
const result = await parseFeedFromXml(feedXml, "http://example.com/feed");
190+
expect(result.error).toBeNull();
191+
192+
const mergedFeed = mergeFeeds(
193+
[{ ...result, url: "http://example.com/feed" }],
194+
"http://example.com/merged",
195+
);
196+
197+
// This should not throw - the bug was that escapeXml received an object
198+
const rssOutput = generateRSS(mergedFeed, "http://example.com/merged");
199+
200+
// Verify the guid is properly extracted
201+
expect(rssOutput).toContain("<guid>https://example.com/?p=12345</guid>");
202+
expect(rssOutput).toContain("<guid>http://example.com/article2</guid>");
203+
204+
// Also verify JSON Feed output works
205+
const jsonOutput = generateJSONFeed(mergedFeed, "http://example.com/merged");
206+
expect(jsonOutput).toContain("https://example.com/?p=12345");
207+
});
208+
165209
it("should parse Aditya Athalye's Eval/Apply Blog feed", async () => {
166210
const feedXml = `<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
167211
<channel>

src/lib/rss.ts

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -385,19 +385,42 @@ export function mergeFeeds(
385385
}
386386

387387
// Helper functions for XML generation
388-
function escapeXml(unsafe: string): string {
389-
if (!unsafe) return "";
388+
// Helper to safely coerce a value to a string.
389+
// Handles cases where rss-parser returns an object (e.g., { $: { isPermaLink: "false" }, _: "actual-guid" })
390+
// when XML elements have attributes.
391+
function safeString(value: unknown): string {
392+
if (value == null) return "";
393+
if (typeof value === "string") return value;
394+
if (typeof value === "number" || typeof value === "boolean")
395+
return String(value);
396+
397+
if (typeof value === "object") {
398+
// rss-parser stores the text content in _ and attributes in $
399+
const v = value as Record<string, unknown>;
400+
if (typeof v._ === "string") return v._;
401+
if (v._ != null) return String(v._);
402+
// If there's no _ property, the element might be attribute-only (malformed)
403+
return "";
404+
}
405+
406+
return String(value);
407+
}
408+
409+
function escapeXml(unsafe: unknown): string {
410+
const str = safeString(unsafe);
411+
if (!str) return "";
390412

391-
return unsafe
413+
return str
392414
.replace(/&/g, "&amp;")
393415
.replace(/</g, "&lt;")
394416
.replace(/>/g, "&gt;")
395417
.replace(/"/g, "&quot;")
396418
.replace(/'/g, "&apos;");
397419
}
398420

399-
function wrapCDATA(content: string): string {
400-
return `<![CDATA[${content}]]>`;
421+
function wrapCDATA(content: unknown): string {
422+
const str = safeString(content);
423+
return `<![CDATA[${str}]]>`;
401424
}
402425

403426
/**
@@ -458,12 +481,7 @@ export function generateRSS(
458481
// Categories
459482
if (item.categories && item.categories.length > 0) {
460483
item.categories.forEach((category: Category) => {
461-
// Handle categories that may be objects with _ property (from rss-parser when they have attributes)
462-
const categoryValue =
463-
typeof category === "string"
464-
? category
465-
: category._ || String(category);
466-
itemXml += ` <category>${escapeXml(categoryValue)}</category>\n`;
484+
itemXml += ` <category>${escapeXml(category)}</category>\n`;
467485
});
468486
}
469487

@@ -507,17 +525,15 @@ export function generateJSONFeed(
507525
home_page_url: mergedFeed.link,
508526
feed_url: requestUrl,
509527
items: mergedFeed.items.map((item) => ({
510-
id: item.guid || item.link || crypto.randomUUID(),
528+
id: safeString(item.guid) || safeString(item.link) || crypto.randomUUID(),
511529
url: item.link,
512530
title: item.title,
513531
content_html: item.content,
514532
content_text: item.contentSnippet,
515533
date_published: item.isoDate || item.pubDate,
516534
author: item.creator ? { name: item.creator } : undefined,
517535
tags: item.categories
518-
? item.categories.map((cat: any) =>
519-
typeof cat === "string" ? cat : cat._ || String(cat),
520-
)
536+
? item.categories.map((cat) => safeString(cat))
521537
: undefined,
522538
})),
523539
};

tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"moduleResolution": "bundler",
1616
"resolveJsonModule": true,
1717
"isolatedModules": true,
18-
"jsx": "react-jsx",
18+
"jsx": "preserve",
1919
"incremental": true,
2020
"plugins": [
2121
{

0 commit comments

Comments
 (0)