feat(admin): per-feed Subscribe chips with copy/open/validate for RSS/Atom/JSON

Replace the stacked RSS/Atom URL rows in the dashboard with a compact
"Subscribe" chip block exposing all three feed formats — including JSON
Feed, previously absent from the admin UI. Each chip carries copy, open,
and validate actions; validation links to the W3C Feed Validator (RSS/Atom)
and validator.jsonfeed.org (JSON). The Table view's RSS+Atom columns fold
into a single Formats column.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-24 23:08:27 +02:00
parent 1a4a479190
commit b3a979fd03
7 changed files with 322 additions and 89 deletions
+1
View File
@@ -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/<opaque-id>`) 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/<your-address>` 404s)
- Cloudflare Email Workers ingestion (no third-party service)
+58
View File
@@ -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://");
});
});
+32
View File
@@ -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(
+7
View File
@@ -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 () => {
+125 -87
View File
@@ -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) => (
</div>
);
const OpenIcon = () => (
<svg
class="chip-icon"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
<polyline points="15 3 21 3 21 9"></polyline>
<line x1="10" y1="14" x2="21" y2="3"></line>
</svg>
);
const ValidateIcon = () => (
<svg
class="chip-icon"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
<polyline points="22 4 12 14.01 9 11.01"></polyline>
</svg>
);
const FORMAT_LABELS: Record<FeedFormat, string> = {
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 (
<div class="format-chip" data-format={format}>
<span class="format-chip-label">{label}</span>
<span class="format-chip-actions">
<span class="copyable copyable-chip">
<span
class="copyable-content"
title={`Copy ${label} feed URL`}
aria-label={`Copy ${label} feed URL`}
>
<span class="copyable-value" data-copy={url} hidden></span>
<span class="copy-icon-container">
<CopyIcon />
<CheckIcon />
</span>
</span>
</span>
<a
class="chip-action"
href={url}
target="_blank"
rel="noopener noreferrer"
title={`Open ${label} feed in a new tab`}
aria-label={`Open ${label} feed in a new tab`}
>
<OpenIcon />
</a>
<a
class="chip-action"
href={validateUrl}
target="_blank"
rel="noopener noreferrer"
title={`Validate ${label} feed`}
aria-label={`Validate ${label} feed`}
>
<ValidateIcon />
</a>
</span>
</div>
);
};
const FeedFormats = ({
feedId,
env,
compact,
}: {
feedId: string;
env: Env;
compact?: boolean;
}) => (
<div class={`feed-formats${compact ? " feed-formats-compact" : ""}`}>
{!compact && <span class="feed-formats-label">Subscribe</span>}
<div class="feed-formats-chips">
<FormatChip format="rss" feedId={feedId} env={env} />
<FormatChip format="atom" feedId={feedId} env={env} />
<FormatChip format="json" feedId={feedId} env={env} />
</div>
</div>
);
function formatExpiry(expiresAt: number): { label: string; expired: boolean } {
const remaining = expiresAt - Date.now();
if (remaining <= 0) {
@@ -522,8 +639,7 @@ app.get("/", async (c) => {
<col data-col="title" style="width: 280px;" />
<col data-col="feedId" style="width: 150px;" />
<col data-col="email" style="width: 200px;" />
<col data-col="rss" style="width: 190px;" />
<col data-col="atom" style="width: 190px;" />
<col data-col="formats" style="width: 230px;" />
<col data-col="expires" style="width: 130px;" />
<col data-col="actions" style="width: 170px;" />
</colgroup>
@@ -602,47 +718,11 @@ app.get("/", async (c) => {
title="Resize"
></div>
</th>
<th
class="th-resizable"
data-sort-key="rss"
aria-sort="none"
>
<button
type="button"
class="th-button"
data-sort-key="rss"
>
RSS
<span
class="sort-indicator"
aria-hidden="true"
></span>
</button>
<th class="th-resizable">
<span>Formats</span>
<div
class="col-resizer"
data-col="rss"
title="Resize"
></div>
</th>
<th
class="th-resizable"
data-sort-key="atom"
aria-sort="none"
>
<button
type="button"
class="th-button"
data-sort-key="atom"
>
Atom
<span
class="sort-indicator"
aria-hidden="true"
></span>
</button>
<div
class="col-resizer"
data-col="atom"
data-col="formats"
title="Resize"
></div>
</th>
@@ -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}
>
<td>
<input
@@ -743,10 +817,7 @@ app.get("/", async (c) => {
<CopyFieldInline value={emailAddress} />
</td>
<td>
<CopyFieldInline value={rssUrl} />
</td>
<td>
<CopyFieldInline value={atomUrl} />
<FeedFormats feedId={feed.id} env={env} compact />
</td>
<td>
{feed.expires_at ? (
@@ -827,8 +898,6 @@ app.get("/", async (c) => {
<ul class="feed-list">
{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) => {
</div>
</div>
</div>
<div class="copyable">
<span class="copyable-label">RSS Feed:</span>
<div class="copyable-content">
<span
class="copyable-value"
data-copy={rssUrl}
title={rssUrl}
>
{rssUrl}
</span>
<div class="copy-icon-container">
<CopyIcon />
<CheckIcon />
</div>
</div>
</div>
<div class="copyable">
<span class="copyable-label">Atom Feed:</span>
<div class="copyable-content">
<span
class="copyable-value"
data-copy={atomUrl}
title={atomUrl}
>
{atomUrl}
</span>
<div class="copy-icon-container">
<CopyIcon />
<CheckIcon />
</div>
</div>
</div>
<FeedFormats feedId={feed.id} env={env} />
</div>
<div class="feed-buttons">
+2 -2
View File
@@ -139,14 +139,14 @@ function setupFeedTableResizing(): void {
title: 220,
feedId: 120,
email: 160,
rss: 160,
formats: 200,
actions: 160,
};
const defaultWidths: Record<string, number> = {
title: 340,
feedId: 160,
email: 220,
rss: 220,
formats: 230,
actions: 200,
};
+97
View File
@@ -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,