diff --git a/src/domain/native-feed.test.ts b/src/domain/native-feed.test.ts new file mode 100644 index 0000000..b77ad19 --- /dev/null +++ b/src/domain/native-feed.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from "vitest"; +import { detectNativeFeeds, unionNativeFeeds } from "./native-feed"; + +describe("detectNativeFeeds", () => { + it("maps the three canonical MIME types to kinds", () => { + expect( + detectNativeFeeds([ + { href: "https://x.com/atom", type: "application/atom+xml" }, + { href: "https://x.com/rss", type: "application/rss+xml" }, + { href: "https://x.com/json", type: "application/feed+json" }, + ]), + ).toEqual([ + { url: "https://x.com/atom", type: "atom" }, + { url: "https://x.com/rss", type: "rss" }, + { url: "https://x.com/json", type: "json" }, + ]); + }); + + it("ignores unknown MIME types (application/json, text/html)", () => { + expect( + detectNativeFeeds([ + { href: "https://x.com/api", type: "application/json" }, + { href: "https://x.com/", type: "text/html" }, + ]), + ).toEqual([]); + }); + + it("strips MIME parameters and is case-insensitive", () => { + expect( + detectNativeFeeds([ + { href: "https://x.com/f", type: "Application/RSS+XML; charset=utf-8" }, + ]), + ).toEqual([{ url: "https://x.com/f", type: "rss" }]); + }); + + it("dedupes by URL (first kind wins)", () => { + expect( + detectNativeFeeds([ + { href: "https://x.com/f", type: "application/rss+xml" }, + { href: "https://x.com/f", type: "application/atom+xml" }, + ]), + ).toEqual([{ url: "https://x.com/f", type: "rss" }]); + }); +}); + +describe("unionNativeFeeds", () => { + it("returns [] for undefined", () => { + expect(unionNativeFeeds(undefined)).toEqual([]); + }); + + it("unions across senders, deduping by URL", () => { + expect( + unionNativeFeeds({ + "a@x.com": [{ url: "https://x.com/rss", type: "rss" }], + "b@y.com": [ + { url: "https://x.com/rss", type: "rss" }, + { url: "https://y.com/atom", type: "atom" }, + ], + }), + ).toEqual([ + { url: "https://x.com/rss", type: "rss" }, + { url: "https://y.com/atom", type: "atom" }, + ]); + }); +}); diff --git a/src/domain/native-feed.ts b/src/domain/native-feed.ts new file mode 100644 index 0000000..f494f57 --- /dev/null +++ b/src/domain/native-feed.ts @@ -0,0 +1,54 @@ +/** + * Pure detection of a newsletter's own syndication feed. No DOM, no I/O — it + * receives already-extracted tuples (infra parses the HTML) and decides + * which ones are real feeds. This module owns the business knowledge: the strict + * set of recognized feed MIME types. + */ +import { NativeFeed } from "../types"; + +// MIME type → feed kind. Strict: only the three canonical syndication types. +// `application/json` is deliberately excluded — too broad, captures non-feeds. +const MIME_TO_KIND: Record = { + "application/atom+xml": "atom", + "application/rss+xml": "rss", + "application/feed+json": "json", +}; + +// Drop MIME parameters ("; charset=…"), trim, lowercase. +function normalizeMime(type: string): string { + return type.split(";")[0].trim().toLowerCase(); +} + +/** Map raw tuples to recognized native feeds, deduped by URL. */ +export function detectNativeFeeds( + links: { href: string; type: string }[], +): NativeFeed[] { + const out: NativeFeed[] = []; + const seen = new Set(); + for (const link of links) { + const kind = MIME_TO_KIND[normalizeMime(link.type)]; + if (!kind) continue; + const url = link.href.trim(); + if (!url || seen.has(url)) continue; + seen.add(url); + out.push({ url, type: kind }); + } + return out; +} + +/** Flatten per-sender native feeds into one list, deduped by URL (first wins). */ +export function unionNativeFeeds( + bySender: Record | undefined, +): NativeFeed[] { + if (!bySender) return []; + const out: NativeFeed[] = []; + const seen = new Set(); + for (const feeds of Object.values(bySender)) { + for (const feed of feeds) { + if (seen.has(feed.url)) continue; + seen.add(feed.url); + out.push({ ...feed }); + } + } + return out; +} diff --git a/src/types/index.ts b/src/types/index.ts index 2c69cb5..f65801c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -69,6 +69,13 @@ export interface FeedMetadata { pendingConfirmation?: boolean; } +// A syndication feed a newsletter advertises about itself (via +// ), as opposed to the KTN-generated feed. +export interface NativeFeed { + url: string; + type: "rss" | "atom" | "json"; +} + // Email metadata interface (summary info for listing) export interface EmailMetadata { key: string;