Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 66 additions & 1 deletion apps/web/lib/rss.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest";
import { buildRss } from "./rss";
import { buildRss, mediaCardsToFeed } from "./rss";
import type { MediaCard } from "./queries";

describe("buildRss", () => {
const xml = buildRss({
Expand Down Expand Up @@ -37,3 +38,67 @@ describe("buildRss", () => {
expect(xml).not.toContain("hello]]></p>]]>");
});
});

describe("mediaCardsToFeed", () => {
const card = (mediaUrl: string): MediaCard => ({
id: "media-1",
slug: "photo",
title: "Photo",
description: null,
mediaType: "image",
mediaUrl,
thumbnailUrl: null,
posterUrl: null,
sourceUrl: null,
sourceProvider: null,
truthLabel: "unknown",
truthConfidence: "unverified",
revealStatus: "revealed",
isFeatured: false,
isScoreEligible: true,
createdAt: "2026-01-01T00:00:00.000Z",
approvedAt: "2026-01-01T00:00:00.000Z",
stats: {
aiGuesses: 0,
notAiGuesses: 0,
totalGuesses: 0,
crowdAccuracy: 0,
aiPct: 0,
},
tags: [],
});

const channel = {
title: "Latest",
link: "https://aiornot.vote",
feedUrl: "https://aiornot.vote/rss.xml",
description: "Latest media",
};

it("uses the image URL's actual MIME type for RSS enclosures", () => {
const xml = mediaCardsToFeed(
[
card("https://cdn.example/photo.jpg"),
{ ...card("https://cdn.example/generated.webp?width=1200"), id: "media-2", slug: "generated" },
],
channel,
);

expect(xml).toContain('url="https://cdn.example/photo.jpg" type="image/jpeg"');
expect(xml).toContain(
'url="https://cdn.example/generated.webp?width=1200" type="image/webp"',
);
});

it("omits an enclosure when the image MIME type cannot be inferred", () => {
const xml = mediaCardsToFeed(
[card("https://picsum.photos/seed/example/1000/1250")],
channel,
);

expect(xml).not.toContain("<enclosure");
expect(xml).toContain(
'<img src="https://picsum.photos/seed/example/1000/1250"',
);
});
});
49 changes: 40 additions & 9 deletions apps/web/lib/rss.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,34 @@ function abs(url: string): string {
return url.startsWith("/") ? `${env.appUrl}${url}` : url;
}

function imageMimeType(url: string): string | null {
let path = url;
try {
path = new URL(url, env.appUrl).pathname;
} catch {
path = url.split(/[?#]/, 1)[0] ?? "";
}

const extension = path.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1];
switch (extension) {
case "jpg":
case "jpeg":
return "image/jpeg";
case "png":
return "image/png";
case "gif":
return "image/gif";
case "webp":
return "image/webp";
case "avif":
return "image/avif";
case "svg":
return "image/svg+xml";
default:
return null;
}
}

export type FeedItem = {
title: string;
link: string;
Expand Down Expand Up @@ -101,15 +129,18 @@ export function mediaCardsToFeed(
): string {
return buildRss({
...channel,
items: cards.map((m) => ({
title: m.title,
link: `${env.appUrl}/m/${m.slug}`,
guid: `media:${m.id}`,
pubDate: m.approvedAt || m.createdAt,
descriptionHtml: mediaItemDescription(m),
categories: m.tags.filter((t) => !t.isAnswerSpoiler).map((t) => t.slug),
enclosure: m.mediaType === "image" ? { url: abs(m.mediaUrl), type: "image/webp" } : undefined,
})),
items: cards.map((m) => {
const enclosureType = m.mediaType === "image" ? imageMimeType(m.mediaUrl) : null;
return {
title: m.title,
link: `${env.appUrl}/m/${m.slug}`,
guid: `media:${m.id}`,
pubDate: m.approvedAt || m.createdAt,
descriptionHtml: mediaItemDescription(m),
categories: m.tags.filter((t) => !t.isAnswerSpoiler).map((t) => t.slug),
enclosure: enclosureType ? { url: abs(m.mediaUrl), type: enclosureType } : undefined,
};
}),
});
}

Expand Down