Skip to content

Commit dcd3c01

Browse files
Merge pull request #97 from deariary/feat/rss-feed
feat: generate RSS feed with OG image URLs during render
2 parents 6936761 + 75f1047 commit dcd3c01

7 files changed

Lines changed: 347 additions & 2 deletions

File tree

src/cli/commands/render.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ vi.mock("../../renderer/og-image.js", () => ({
4141
generateIndexOGImage: (...args: unknown[]) => mockGenerateIndexOGImage(...args),
4242
}));
4343

44+
// Mock RSS
45+
const mockBuildRSSFeed = vi.fn().mockReturnValue("<?xml version=\"1.0\"?><rss></rss>");
46+
vi.mock("../../renderer/rss.js", () => ({
47+
buildRSSFeed: (...args: unknown[]) => mockBuildRSSFeed(...args),
48+
}));
49+
4450
// Mock week
4551
vi.mock("../../deployer/week.js", () => ({
4652
getWeekId: () => ({ year: 2026, week: 14, path: "2026/W14" }),
@@ -146,6 +152,14 @@ describe("registerRender", () => {
146152
"utf-8",
147153
);
148154

155+
// Should write RSS feed
156+
expect(mockBuildRSSFeed).toHaveBeenCalled();
157+
expect(mockWriteFile).toHaveBeenCalledWith(
158+
expect.stringContaining("feed.xml"),
159+
expect.stringContaining("<?xml"),
160+
"utf-8",
161+
);
162+
149163
// Should render index page
150164
expect(mockRenderIndexPage).toHaveBeenCalled();
151165

src/cli/commands/render.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { renderIndexPage, buildReportEntry, type ReportEntry } from "../../deplo
99
import { getWeekId } from "../../deployer/week.js";
1010
import { parseLocalDate } from "../../collector/date-range.js";
1111
import { generateOGImage, generateIndexOGImage } from "../../renderer/og-image.js";
12+
import { buildRSSFeed } from "../../renderer/rss.js";
1213
import type { WeeklyReportData, AIContent, Language } from "../../types.js";
1314

1415
const env = (key: string): string | undefined => process.env[key];
@@ -71,7 +72,8 @@ const buildReportEntries = async (
7172
prs: ghData.stats.prsOpened,
7273
reviews: ghData.stats.prsReviewed,
7374
} : undefined;
74-
return buildReportEntry(path, llmData.title, llmData.subtitle, stats);
75+
const dateTo = ghData?.dateRange?.to;
76+
return buildReportEntry(path, llmData.title, llmData.subtitle, stats, dateTo, llmData.overview);
7577
}),
7678
);
7779
return entries.filter((e): e is ReportEntry => e !== null);
@@ -205,6 +207,18 @@ ${sitemapEntries}
205207
await writeFile(sitemapPath, sitemap, "utf-8");
206208
console.log(`Sitemap written to ${sitemapPath}`);
207209

210+
// Generate RSS feed
211+
const rssFeed = buildRSSFeed(entries, {
212+
title: resolvedSiteTitle,
213+
link: base,
214+
description: `Weekly reports by @${githubData.username}`,
215+
language: options.language,
216+
timezone: options.timezone,
217+
});
218+
const feedPath = join(options.outputDir, "feed.xml");
219+
await writeFile(feedPath, rssFeed, "utf-8");
220+
console.log(`RSS feed written to ${feedPath}`);
221+
208222
// Generate robots.txt
209223
const robots = `User-agent: *\nAllow: /\nSitemap: ${base}/sitemap.xml\n`;
210224
const robotsPath = join(options.outputDir, "robots.txt");
@@ -226,7 +240,7 @@ export const registerRender = (program: Command): void => {
226240
.description("Render HTML report from fetched data and LLM content")
227241
.option("--data-dir <dir>", "Data directory (env: DATA_DIR, default: ./data)")
228242
.option("-o, --output-dir <dir>", "Output directory for HTML (env: OUTPUT_DIR, default: ./output)")
229-
.option("--base-url <url>", "Base URL for absolute links in OG tags, sitemap, canonical (env: BASE_URL)")
243+
.option("--base-url <url>", "Base URL for absolute links in OG tags, sitemap, RSS feed, canonical (env: BASE_URL)")
230244
.option("--site-title <title>", "Site title for nav header (env: SITE_TITLE, default: {username}'s Weekly Reports)")
231245
.option("--language <lang>", "Report language: en, ja, zh-CN, zh-TW, ko, es, fr, de, pt, ru (env: LANGUAGE, default: en)")
232246
.option("--timezone <tz>", "IANA timezone (env: TIMEZONE, default: UTC)")

src/deployer/index-page.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ export type ReportEntry = {
2626
year: string;
2727
title?: string;
2828
subtitle?: string;
29+
overview?: string; // LLM-generated multi-paragraph overview text
2930
dateLabel: string;
31+
dateTo?: string; // ISO date (YYYY-MM-DD) of the week's last day
3032
stats?: ReportEntryStats;
3133
};
3234

@@ -101,12 +103,16 @@ export const buildReportEntry = (
101103
title?: string,
102104
subtitle?: string,
103105
stats?: ReportEntryStats,
106+
dateTo?: string,
107+
overview?: string,
104108
): ReportEntry => ({
105109
path,
106110
week: path.split("/")[1] ?? path,
107111
year: path.split("/")[0] ?? "",
108112
title,
109113
subtitle,
114+
overview,
110115
dateLabel: weekToDateLabel(path),
116+
dateTo,
111117
stats,
112118
});

src/renderer/rss.test.ts

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import { describe, it, expect } from "vitest";
2+
import { buildRSSFeed } from "./rss.js";
3+
import type { ReportEntry } from "../deployer/index-page.js";
4+
5+
const makeEntry = (
6+
path: string,
7+
title: string,
8+
subtitle: string,
9+
dateTo?: string,
10+
): ReportEntry => ({
11+
path,
12+
week: path.split("/")[1] ?? path,
13+
year: path.split("/")[0] ?? "",
14+
title,
15+
subtitle,
16+
dateLabel: `${path.split("/")[0]} ${path.split("/")[1]}`,
17+
dateTo,
18+
});
19+
20+
const defaultChannel = (overrides: Record<string, string> = {}) => ({
21+
title: "Dev Pulse",
22+
link: "https://user.github.io/repo",
23+
description: "Weekly reports by @testuser",
24+
language: "en",
25+
timezone: "UTC",
26+
...overrides,
27+
});
28+
29+
describe("buildRSSFeed", () => {
30+
it("generates valid RSS 2.0 XML with items sorted newest-first", () => {
31+
const entries: ReportEntry[] = [
32+
makeEntry("2026/W12", "Week 12 Title", "Week 12 subtitle", "2026-03-22"),
33+
makeEntry("2026/W14", "Week 14 Title", "Week 14 subtitle", "2026-04-05"),
34+
makeEntry("2026/W13", "Week 13 Title", "Week 13 subtitle", "2026-03-29"),
35+
];
36+
37+
const feed = buildRSSFeed(entries, defaultChannel());
38+
39+
expect(feed).toContain('<?xml version="1.0" encoding="UTF-8"?>');
40+
expect(feed).toContain('<rss version="2.0"');
41+
expect(feed).toContain("<title>Dev Pulse</title>");
42+
expect(feed).toContain("<link>https://user.github.io/repo/</link>");
43+
expect(feed).toContain("<description>Weekly reports by @testuser</description>");
44+
expect(feed).toContain("<language>en</language>");
45+
46+
// Items should be sorted newest first (W14, W13, W12)
47+
const w14Pos = feed.indexOf("Week 14 Title");
48+
const w13Pos = feed.indexOf("Week 13 Title");
49+
const w12Pos = feed.indexOf("Week 12 Title");
50+
expect(w14Pos).toBeLessThan(w13Pos);
51+
expect(w13Pos).toBeLessThan(w12Pos);
52+
});
53+
54+
it("includes OG image in enclosure and media:content", () => {
55+
const entries = [makeEntry("2026/W14", "Title", "Subtitle", "2026-04-05")];
56+
const feed = buildRSSFeed(entries, defaultChannel({ link: "https://example.com" }));
57+
58+
// enclosure tag
59+
expect(feed).toContain('url="https://example.com/2026/W14/og.png"');
60+
expect(feed).toContain('type="image/png"');
61+
62+
// media:content with dimensions
63+
expect(feed).toContain('<media:content url="https://example.com/2026/W14/og.png"');
64+
expect(feed).toContain('width="1200"');
65+
expect(feed).toContain('height="630"');
66+
expect(feed).toContain('medium="image"');
67+
});
68+
69+
it("includes permalink guids", () => {
70+
const entries = [makeEntry("2026/W14", "Title", "Subtitle", "2026-04-05")];
71+
const feed = buildRSSFeed(entries, defaultChannel({ link: "https://example.com" }));
72+
73+
expect(feed).toContain('<guid isPermaLink="true">https://example.com/2026/W14/</guid>');
74+
});
75+
76+
it("escapes XML special characters", () => {
77+
const entries = [makeEntry("2026/W14", "Title <>&\"'", "Sub <>&", "2026-04-05")];
78+
const feed = buildRSSFeed(entries, defaultChannel({
79+
title: "Site & Title",
80+
description: "A <desc>",
81+
}));
82+
83+
expect(feed).toContain("Site &amp; Title");
84+
expect(feed).toContain("Title &lt;&gt;&amp;&quot;&apos;");
85+
expect(feed).toContain("Sub &lt;&gt;&amp;");
86+
expect(feed).toContain("A &lt;desc&gt;");
87+
});
88+
89+
it("includes atom:link for self-reference", () => {
90+
const entries = [makeEntry("2026/W14", "Title", "Sub", "2026-04-05")];
91+
const feed = buildRSSFeed(entries, defaultChannel({ language: "ja" }));
92+
93+
expect(feed).toContain('href="https://user.github.io/repo/feed.xml"');
94+
expect(feed).toContain('rel="self"');
95+
expect(feed).toContain("<language>ja</language>");
96+
});
97+
98+
it("computes pubDate as Monday 01:00 local in UTC timezone", () => {
99+
// dateRange.to = 2026-04-05 (Sunday), so pubDate = Monday 2026-04-06 01:00 UTC
100+
const entries = [makeEntry("2026/W14", "Title", "Sub", "2026-04-05")];
101+
const feed = buildRSSFeed(entries, defaultChannel({ timezone: "UTC" }));
102+
103+
expect(feed).toContain("<pubDate>");
104+
expect(feed).toContain("Mon, 06 Apr 2026 01:00:00 GMT");
105+
});
106+
107+
it("computes pubDate with Asia/Tokyo timezone", () => {
108+
// dateRange.to = 2026-04-05 (Sunday)
109+
// Monday 01:00 JST = Sunday 16:00 UTC
110+
const entries = [makeEntry("2026/W14", "Title", "Sub", "2026-04-05")];
111+
const feed = buildRSSFeed(entries, defaultChannel({ timezone: "Asia/Tokyo" }));
112+
113+
expect(feed).toContain("Sun, 05 Apr 2026 16:00:00 GMT");
114+
});
115+
116+
it("computes pubDate with US/Eastern timezone", () => {
117+
// dateRange.to = 2026-04-05 (Sunday), EDT is UTC-4 in April
118+
// Monday 01:00 EDT = Monday 05:00 UTC
119+
const entries = [makeEntry("2026/W14", "Title", "Sub", "2026-04-05")];
120+
const feed = buildRSSFeed(entries, defaultChannel({ timezone: "US/Eastern" }));
121+
122+
expect(feed).toContain("Mon, 06 Apr 2026 05:00:00 GMT");
123+
});
124+
125+
it("omits pubDate when dateTo is not available", () => {
126+
const entry: ReportEntry = {
127+
path: "2026/W14",
128+
week: "W14",
129+
year: "2026",
130+
title: undefined,
131+
subtitle: undefined,
132+
dateLabel: "2026 W14",
133+
};
134+
135+
const feed = buildRSSFeed([entry], defaultChannel());
136+
137+
expect(feed).toContain("<title>2026 W14</title>");
138+
expect(feed).not.toContain("<pubDate>");
139+
});
140+
141+
it("includes overview and stats in description", () => {
142+
const entry: ReportEntry = {
143+
path: "2026/W14",
144+
week: "W14",
145+
year: "2026",
146+
title: "Big Week",
147+
subtitle: "Shipped auth refactor",
148+
overview: "This week focused on the auth refactor. The new OAuth2 flow is live.",
149+
dateLabel: "2026 W14",
150+
dateTo: "2026-04-05",
151+
stats: { commits: 42, prs: 5, reviews: 8 },
152+
};
153+
154+
const feed = buildRSSFeed([entry], defaultChannel());
155+
156+
expect(feed).toContain("Shipped auth refactor");
157+
expect(feed).toContain("This week focused on the auth refactor");
158+
expect(feed).toContain("Commits: 42, PRs: 5, Reviews: 8");
159+
});
160+
161+
it("includes only subtitle when overview and stats are absent", () => {
162+
const entries = [makeEntry("2026/W14", "Title", "Just a subtitle", "2026-04-05")];
163+
const feed = buildRSSFeed(entries, defaultChannel());
164+
165+
expect(feed).toContain("Just a subtitle");
166+
expect(feed).not.toContain("Commits:");
167+
});
168+
169+
it("includes channel image with site OG image", () => {
170+
const feed = buildRSSFeed([], defaultChannel({ link: "https://example.com" }));
171+
172+
expect(feed).toContain("<image>");
173+
expect(feed).toContain("<url>https://example.com/og.png</url>");
174+
expect(feed).toContain("<width>1200</width>");
175+
expect(feed).toContain("<height>630</height>");
176+
});
177+
178+
it("includes media RSS namespace", () => {
179+
const feed = buildRSSFeed([], defaultChannel());
180+
181+
expect(feed).toContain('xmlns:media="http://search.yahoo.com/mrss/"');
182+
});
183+
184+
it("returns empty items for empty entries", () => {
185+
const feed = buildRSSFeed([], defaultChannel());
186+
187+
expect(feed).toContain("<channel>");
188+
expect(feed).not.toContain("<item>");
189+
});
190+
});

0 commit comments

Comments
 (0)