diff --git a/README.md b/README.md index 5db6b28..a256aa0 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ kill-the-news keeps the same workflow while avoiding shared domains and shared d - Bulk feed/email deletion from the admin dashboard (safe checkbox-based flow) - Inline double-confirm delete interactions with toast feedback in the admin dashboard - Resizable + sortable table columns in the admin dashboard (Table view) +- Per-feed "Subscribe" chips in the admin dashboard — copy, open, or validate the feed in one click for each of RSS, Atom, and JSON Feed (validation via the W3C Feed Validator and validator.jsonfeed.org) - Unique newsletter addresses per feed (for example `apple.mountain.42@yourdomain.com`) - **Separate inbound address and feed URL** — the address you subscribe with (`apple.mountain.42@yourdomain.com`) and the public feed URL (`/rss/`) use **independent** ids, so you can share a feed without leaking the address that feeds it, and an address harvested by a newsletter can't be used to read your feed (`/rss/` 404s) - Cloudflare Email Workers ingestion (no third-party service) diff --git a/src/infrastructure/urls.test.ts b/src/infrastructure/urls.test.ts new file mode 100644 index 0000000..4746e3f --- /dev/null +++ b/src/infrastructure/urls.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from "vitest"; +import { + feedRssUrl, + feedAtomUrl, + feedJsonUrl, + feedFormatUrl, + feedValidatorUrl, +} from "./urls"; +import { Env } from "../types"; + +const env = { DOMAIN: "getmynews.app" } as Env; +const feedId = "gAf6wiKyanpppcKX9o3B_Q"; + +describe("feed URL builders", () => { + it("builds RSS/Atom/JSON feed URLs", () => { + expect(feedRssUrl(feedId, env)).toBe( + "https://getmynews.app/rss/gAf6wiKyanpppcKX9o3B_Q", + ); + expect(feedAtomUrl(feedId, env)).toBe( + "https://getmynews.app/atom/gAf6wiKyanpppcKX9o3B_Q", + ); + expect(feedJsonUrl(feedId, env)).toBe( + "https://getmynews.app/json/gAf6wiKyanpppcKX9o3B_Q", + ); + }); + + it("resolves a format to its feed URL", () => { + expect(feedFormatUrl("rss", feedId, env)).toBe(feedRssUrl(feedId, env)); + expect(feedFormatUrl("atom", feedId, env)).toBe(feedAtomUrl(feedId, env)); + expect(feedFormatUrl("json", feedId, env)).toBe(feedJsonUrl(feedId, env)); + }); +}); + +describe("feedValidatorUrl", () => { + it("points JSON feeds at validator.jsonfeed.org with the encoded feed URL", () => { + expect(feedValidatorUrl("json", feedId, env)).toBe( + "https://validator.jsonfeed.org/?url=" + + encodeURIComponent(feedJsonUrl(feedId, env)), + ); + }); + + it("points RSS and Atom feeds at the W3C feed validator", () => { + expect(feedValidatorUrl("rss", feedId, env)).toBe( + "https://validator.w3.org/feed/check.cgi?url=" + + encodeURIComponent(feedRssUrl(feedId, env)), + ); + expect(feedValidatorUrl("atom", feedId, env)).toBe( + "https://validator.w3.org/feed/check.cgi?url=" + + encodeURIComponent(feedAtomUrl(feedId, env)), + ); + }); + + it("percent-encodes the feed URL so the validator query is well-formed", () => { + const url = feedValidatorUrl("json", feedId, env); + expect(url).toContain("https%3A%2F%2Fgetmynews.app%2Fjson%2F"); + expect(url).not.toContain("?url=https://"); + }); +}); diff --git a/src/infrastructure/urls.ts b/src/infrastructure/urls.ts index a6c26bf..72e494e 100644 --- a/src/infrastructure/urls.ts +++ b/src/infrastructure/urls.ts @@ -13,6 +13,10 @@ export function feedAtomUrl(feedId: string, env: Env): string { return `${baseUrl(env)}/atom/${feedId}`; } +export function feedJsonUrl(feedId: string, env: Env): string { + return `${baseUrl(env)}/json/${feedId}`; +} + export function feedUrl( format: "rss" | "atom", feedId: string, @@ -21,6 +25,34 @@ export function feedUrl( return format === "rss" ? feedRssUrl(feedId, env) : feedAtomUrl(feedId, env); } +export type FeedFormat = "rss" | "atom" | "json"; + +export function feedFormatUrl( + format: FeedFormat, + feedId: string, + env: Env, +): string { + if (format === "atom") return feedAtomUrl(feedId, env); + if (format === "json") return feedJsonUrl(feedId, env); + return feedRssUrl(feedId, env); +} + +/** + * Link to a third-party validator for the public feed URL: the W3C Feed + * Validator for RSS/Atom, validator.jsonfeed.org for JSON Feed. Used in the + * admin UI so an operator can confirm a feed parses in real readers. + */ +export function feedValidatorUrl( + format: FeedFormat, + feedId: string, + env: Env, +): string { + const encoded = encodeURIComponent(feedFormatUrl(format, feedId, env)); + return format === "json" + ? `https://validator.jsonfeed.org/?url=${encoded}` + : `https://validator.w3.org/feed/check.cgi?url=${encoded}`; +} + export function feedEmailAddress(mailboxId: string, env: Env): string { // The mailbox→address shape lives on the VO; this edge only resolves the domain. return MailboxId.unchecked(mailboxId).emailAddress( diff --git a/src/routes/admin.test.ts b/src/routes/admin.test.ts index 5f6918c..b32780b 100644 --- a/src/routes/admin.test.ts +++ b/src/routes/admin.test.ts @@ -189,6 +189,13 @@ describe("Admin Routes", () => { expect(html).toContain(`${mailboxId}@test.getmynews.app`); expect(html).toContain(`/rss/${feedId}`); expect(html).not.toContain(`/rss/${mailboxId}`); + + // The feed-formats block surfaces all three formats (incl. JSON Feed) + // plus per-format validator links. + expect(html).toContain(`/atom/${feedId}`); + expect(html).toContain(`/json/${feedId}`); + expect(html).toContain("validator.jsonfeed.org"); + expect(html).toContain("validator.w3.org/feed"); }); it("should reject feed creation with missing title", async () => { diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx index 8d66b28..43ac8a0 100644 --- a/src/routes/admin.tsx +++ b/src/routes/admin.tsx @@ -12,9 +12,10 @@ import { FeedRepository } from "../infrastructure/feed-repository"; import { FeedId } from "../domain/value-objects/feed-id"; import { editFeedDetails } from "../application/feed-service"; import { - feedRssUrl, - feedAtomUrl, feedEmailAddress, + feedFormatUrl, + feedValidatorUrl, + type FeedFormat, } from "../infrastructure/urls"; import { feedsRouter } from "./admin/feeds"; import { emailsRouter } from "./admin/emails"; @@ -255,6 +256,122 @@ const CopyFieldInline = ({ value }: CopyFieldInlineProps) => ( ); +const OpenIcon = () => ( + +); + +const ValidateIcon = () => ( + +); + +const FORMAT_LABELS: Record = { + rss: "RSS", + atom: "Atom", + json: "JSON", +}; + +const FormatChip = ({ + format, + feedId, + env, +}: { + format: FeedFormat; + feedId: string; + env: Env; +}) => { + const url = feedFormatUrl(format, feedId, env); + const validateUrl = feedValidatorUrl(format, feedId, env); + const label = FORMAT_LABELS[format]; + return ( +
+ {label} + + + + + + + + + + + + + + + + + +
+ ); +}; + +const FeedFormats = ({ + feedId, + env, + compact, +}: { + feedId: string; + env: Env; + compact?: boolean; +}) => ( +
+ {!compact && Subscribe} +
+ + + +
+
+); + function formatExpiry(expiresAt: number): { label: string; expired: boolean } { const remaining = expiresAt - Date.now(); if (remaining <= 0) { @@ -522,8 +639,7 @@ app.get("/", async (c) => { - - + @@ -602,47 +718,11 @@ app.get("/", async (c) => { title="Resize" > - - + + Formats
- - - -
@@ -670,15 +750,11 @@ app.get("/", async (c) => { feed.mailbox_id, env, ); - const rssUrl = feedRssUrl(feed.id, env); - const atomUrl = feedAtomUrl(feed.id, env); const titleDisplay = clampText(feed.title, 160); const titleHover = clampText(feed.title, 1000); const sortTitle = titleHover.toLowerCase(); const sortFeedId = feed.id.toLowerCase(); const sortEmail = emailAddress.toLowerCase(); - const sortRss = rssUrl.toLowerCase(); - const sortAtom = atomUrl.toLowerCase(); const descDisplay = clampText( feed.description || "", 220, @@ -698,8 +774,6 @@ app.get("/", async (c) => { data-sort-title={sortTitle} data-sort-feed-id={sortFeedId} data-sort-email={sortEmail} - data-sort-rss={sortRss} - data-sort-atom={sortAtom} > { - - - - + {feed.expires_at ? ( @@ -827,8 +898,6 @@ app.get("/", async (c) => {
    {feedsWithConfig.map((feed) => { const emailAddress = feedEmailAddress(feed.mailbox_id, env); - const rssUrl = feedRssUrl(feed.id, env); - const atomUrl = feedAtomUrl(feed.id, env); const titleDisplay = clampText(feed.title, 140); const titleHover = clampText(feed.title, 1000); const descDisplay = clampText(feed.description || "", 240); @@ -884,38 +953,7 @@ app.get("/", async (c) => { -
    - RSS Feed: -
    - - {rssUrl} - -
    - - -
    -
    -
    -
    - Atom Feed: -
    - - {atomUrl} - -
    - - -
    -
    -
    +
    diff --git a/src/scripts/client/dashboard.ts b/src/scripts/client/dashboard.ts index 0356b39..40b1547 100644 --- a/src/scripts/client/dashboard.ts +++ b/src/scripts/client/dashboard.ts @@ -139,14 +139,14 @@ function setupFeedTableResizing(): void { title: 220, feedId: 120, email: 160, - rss: 160, + formats: 200, actions: 160, }; const defaultWidths: Record = { title: 340, feedId: 160, email: 220, - rss: 220, + formats: 230, actions: 200, }; diff --git a/src/styles/components.css b/src/styles/components.css index 2f35a67..f07e557 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -887,6 +887,103 @@ table.table code { flex-wrap: wrap; } +/* ── Feed format chips ("Subscribe" block) ── */ +.feed-formats { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.feed-formats-label { + font-weight: var(--font-weight-semibold); + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + user-select: none; +} + +.feed-formats-chips { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-sm); +} + +.format-chip { + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); + padding: 4px 6px 4px 10px; + border: 1px solid var(--color-border); + border-radius: 999px; + background-color: rgba(60, 60, 67, 0.06); + transition: + border-color var(--transition-fast), + background-color var(--transition-fast); +} + +.format-chip:hover { + border-color: var(--color-primary); +} + +.format-chip-label { + font-weight: var(--font-weight-semibold); + font-size: var(--font-size-sm); + letter-spacing: 0.02em; + user-select: none; +} + +.format-chip-actions { + display: inline-flex; + align-items: center; + gap: 2px; +} + +.chip-action, +.copyable.copyable-chip .copyable-content { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + padding: 0; + border-radius: var(--radius-sm); + color: var(--color-text-secondary); + cursor: pointer; + transition: + background-color var(--transition-fast), + color var(--transition-fast); +} + +.chip-action:hover, +.copyable.copyable-chip .copyable-content:hover { + background-color: rgba(60, 60, 67, 0.18); + color: var(--color-primary); +} + +/* Strip the boxed wrapper styling so only the icon button shows in a chip */ +.copyable.copyable-chip { + margin: 0; + padding: 0; + background: transparent; + border: none; +} + +.copyable.copyable-chip .copyable-content { + flex: 0 0 auto; +} + +.chip-icon { + flex: 0 0 auto; +} + +/* Compact variant for the feeds table cell */ +.feed-formats-compact .feed-formats-chips { + gap: 6px; +} + +.feed-formats-compact .format-chip { + padding: 2px 4px 2px 8px; +} + .button-delete { transition: background-color 180ms ease,