diff --git a/docs/assets/widget_feed_grid.webp b/docs/assets/widget_feed_grid.webp
new file mode 100644
index 000000000..15ffc2e8a
Binary files /dev/null and b/docs/assets/widget_feed_grid.webp differ
diff --git a/docs/assets/widget_feed_list.webp b/docs/assets/widget_feed_list.webp
new file mode 100644
index 000000000..3d07d2ea9
Binary files /dev/null and b/docs/assets/widget_feed_list.webp differ
diff --git a/docs/assets/widget_feed_thumbnails.webp b/docs/assets/widget_feed_thumbnails.webp
new file mode 100644
index 000000000..d23c55228
Binary files /dev/null and b/docs/assets/widget_feed_thumbnails.webp differ
diff --git a/docs/widgets/services/feed.md b/docs/widgets/services/feed.md
new file mode 100644
index 000000000..b5cfedc46
--- /dev/null
+++ b/docs/widgets/services/feed.md
@@ -0,0 +1,46 @@
+---
+title: Feed
+description: RSS / Atom feed widget
+---
+
+This widget shows the latest items from an RSS 2.0 or Atom feed.
+
+```yaml
+widget:
+ type: feed
+ url: https://feeds.bbci.co.uk/news/rss.xml
+ maxItems: 5 # optional - defaults to 5
+ layout: list # optional - possible values list, grid - defaults to list
+ images: true # optional - set to false to hide images - defaults to true
+```
+
+## Layouts
+
+`list` shows one row per item. Items with an image get a small thumbnail.
+
+
+
+
+
+`grid` shows image tiles that re-flow to fit the width of the widget, e.g.:
+
+```yaml
+- NASA Image of the Day:
+ widget:
+ type: feed
+ url: https://www.nasa.gov/feeds/iotd-feed/
+ layout: grid
+ maxItems: 8
+```
+
+
+
+## Images
+
+Images are taken from the feed's `media:thumbnail` / `media:content` tags or image enclosures, falling back to the first image in the item's content. Some feeds do not include images at all.
+
+Images are loaded by your browser directly from the feed's host, at whatever size the feed provides. Feeds that link full-size originals can be slow to load; set `images: false` for those.
+
+## Notes
+
+Feeds are fetched by homepage, not your browser, and cached for 10 minutes.
diff --git a/docs/widgets/services/index.md b/docs/widgets/services/index.md
index e982fb9b9..d99752e0f 100644
--- a/docs/widgets/services/index.md
+++ b/docs/widgets/services/index.md
@@ -39,6 +39,7 @@ You can also find a list of all available service widgets in the sidebar navigat
- [Emby](emby.md)
- [ESPHome](esphome.md)
- [EVCC](evcc.md)
+- [Feed](feed.md)
- [Filebrowser](filebrowser.md)
- [Fileflows](fileflows.md)
- [Firefly III](firefly.md)
diff --git a/mkdocs.yml b/mkdocs.yml
index 0af27d41b..6c38350a2 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -65,6 +65,7 @@ nav:
- widgets/services/emby.md
- widgets/services/esphome.md
- widgets/services/evcc.md
+ - widgets/services/feed.md
- widgets/services/filebrowser.md
- widgets/services/fileflows.md
- widgets/services/firefly.md
diff --git a/package.json b/package.json
index 94f3fb729..1922d9b03 100644
--- a/package.json
+++ b/package.json
@@ -39,6 +39,7 @@
"react-i18next": "^17.0.12",
"react-icons": "^5.6.0",
"recharts": "^3.1.2",
+ "sax": "^1.6.1",
"swr": "^2.5.1",
"systeminformation": "^5.33.1",
"tough-cookie": "^6.0.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 58e45faaa..467952842 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -83,6 +83,9 @@ importers:
recharts:
specifier: ^3.1.2
version: 3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@17.0.2)(react@19.2.8)(redux@5.0.1)
+ sax:
+ specifier: ^1.6.1
+ version: 1.6.1
swr:
specifier: ^2.5.1
version: 2.5.1(react@19.2.8)
diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index ba4ee5f55..b29a4452a 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -1258,5 +1258,8 @@
"wanted": "Wanted",
"queued": "Queued",
"leagues": "Leagues"
+ },
+ "feed": {
+ "noItems": "No items"
}
}
diff --git a/src/utils/config/service-helpers.js b/src/utils/config/service-helpers.js
index 9a9270d3d..20303af7d 100644
--- a/src/utils/config/service-helpers.js
+++ b/src/utils/config/service-helpers.js
@@ -353,6 +353,9 @@ export function cleanServiceGroups(groups) {
expandOneStreamToTwoRows,
showEpisodeNumber,
+ // feed
+ layout,
+
// frigate
enableRecentEvents,
@@ -675,6 +678,9 @@ export function cleanServiceGroups(groups) {
if (showTime) widget.showTime = showTime;
if (timezone) widget.timezone = timezone;
}
+ if (type === "feed") {
+ if (layout) widget.layout = layout;
+ }
if (type === "dockhand") {
if (environment) widget.environment = environment;
}
diff --git a/src/utils/config/service-helpers.test.js b/src/utils/config/service-helpers.test.js
index 7109b9abf..6d6174526 100644
--- a/src/utils/config/service-helpers.test.js
+++ b/src/utils/config/service-helpers.test.js
@@ -524,6 +524,38 @@ describe("utils/config/service-helpers", () => {
]);
});
+ it("cleanServiceGroups keeps feed layout and drops server-side options", async () => {
+ const mod = await import("./service-helpers");
+ const { cleanServiceGroups } = mod;
+
+ const rawGroups = [
+ {
+ name: "Core",
+ services: [
+ {
+ name: "News",
+ widgets: [
+ {
+ type: "feed",
+ url: "https://example.com/feed.xml?token=secret",
+ maxItems: 3,
+ images: false,
+ layout: "grid",
+ },
+ ],
+ },
+ ],
+ groups: [],
+ },
+ ];
+
+ const feedWidget = cleanServiceGroups(rawGroups)[0].services[0].widgets[0];
+ expect(feedWidget).toEqual(expect.objectContaining({ type: "feed", layout: "grid" }));
+ expect(feedWidget).not.toHaveProperty("url");
+ expect(feedWidget).not.toHaveProperty("maxItems");
+ expect(feedWidget).not.toHaveProperty("images");
+ });
+
it("findGroupByName deep-searches and annotates parent", async () => {
const mod = await import("./service-helpers");
const { findGroupByName } = mod;
diff --git a/src/widgets/components.js b/src/widgets/components.js
index dc1c87c9f..ae8327578 100644
--- a/src/widgets/components.js
+++ b/src/widgets/components.js
@@ -37,6 +37,7 @@ const components = {
emby: dynamic(() => import("./emby/component")),
esphome: dynamic(() => import("./esphome/component")),
evcc: dynamic(() => import("./evcc/component")),
+ feed: dynamic(() => import("./feed/component")),
filebrowser: dynamic(() => import("./filebrowser/component")),
fileflows: dynamic(() => import("./fileflows/component")),
firefly: dynamic(() => import("./firefly/component")),
diff --git a/src/widgets/feed/component.jsx b/src/widgets/feed/component.jsx
new file mode 100644
index 000000000..479511711
--- /dev/null
+++ b/src/widgets/feed/component.jsx
@@ -0,0 +1,125 @@
+/* eslint-disable @next/next/no-img-element */
+import classNames from "classnames";
+import { useTranslation } from "next-i18next/pages";
+import { useContext } from "react";
+
+import Container from "components/services/widget/container";
+import { SettingsContext } from "utils/contexts/settings";
+import useWidgetAPI from "utils/proxy/use-widget-api";
+
+const rowClassName = "rounded-md bg-theme-200/50 dark:bg-theme-900/20 text-theme-700 dark:text-theme-200 text-xs";
+const hideBrokenImage = (e) => {
+ e.currentTarget.style.visibility = "hidden";
+};
+
+function FeedLink({ item, target, className, children }) {
+ if (!item.link) return
{children}
;
+ return (
+
+ {children}
+
+ );
+}
+
+function RelativeDate({ date }) {
+ const { t } = useTranslation();
+ if (!date) return null;
+ return (
+
+ {t("common.relativeDate", { value: date, formatParams: { value: { style: "narrow", numeric: "auto" } } })}
+
+ );
+}
+
+function ListItem({ item, target }) {
+ return (
+
+ {item.image && (
+
+ )}
+ {item.title}
+
+
+ );
+}
+
+function GridItem({ item, target }) {
+ return (
+
+
+ {item.image && (
+

+ )}
+
+
+ {item.title}
+
+
+
+ );
+}
+
+export default function Component({ service }) {
+ const { t } = useTranslation();
+ const { settings } = useContext(SettingsContext);
+ const { widget } = service;
+
+ const { data, error } = useWidgetAPI(widget, undefined, undefined, { refreshInterval: 10 * 60 * 1000 });
+
+ if (error) {
+ return ;
+ }
+
+ const target = service.target ?? settings?.target ?? "_blank";
+ const isGrid = widget.layout === "grid";
+ const Item = isGrid ? GridItem : ListItem;
+
+ let content;
+ if (!data) {
+ content = [0, 1, 2].map((i) => (
+
+ ));
+ } else if (!data.items?.length) {
+ content = {t("feed.noItems")}
;
+ } else {
+ content = data.items.map((item, i) => );
+ }
+
+ return (
+
+
+ {content}
+
+
+ );
+}
diff --git a/src/widgets/feed/component.test.jsx b/src/widgets/feed/component.test.jsx
new file mode 100644
index 000000000..480a9affc
--- /dev/null
+++ b/src/widgets/feed/component.test.jsx
@@ -0,0 +1,80 @@
+// @vitest-environment jsdom
+
+import { screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { renderWithProviders } from "test-utils/render-with-providers";
+
+const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
+vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
+
+import Component from "./component";
+
+const items = [
+ {
+ title: "With image",
+ link: "https://example.com/1",
+ date: "2020-01-01T00:00:00Z",
+ image: "https://example.com/1.jpg",
+ },
+ { title: "No link", link: null, date: null },
+];
+
+function render(widget = {}, settings = {}) {
+ return renderWithProviders(, {
+ settings: { hideErrors: false, ...settings },
+ });
+}
+
+describe("widgets/feed/component", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders placeholders while loading", () => {
+ useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
+ const { container } = render();
+ expect(container.querySelectorAll(".animate-pulse")).toHaveLength(3);
+ });
+
+ it("renders an error", () => {
+ useWidgetAPI.mockReturnValue({ data: undefined, error: { message: "Invalid feed" } });
+ render();
+ expect(screen.getAllByText(/Invalid feed/).length).toBeGreaterThan(0);
+ });
+
+ it("renders an empty feed", () => {
+ useWidgetAPI.mockReturnValue({ data: { items: [] }, error: undefined });
+ render();
+ expect(screen.getByText("feed.noItems")).toBeInTheDocument();
+ });
+
+ it("renders list items with links and images", () => {
+ useWidgetAPI.mockReturnValue({ data: { items }, error: undefined });
+ const { container } = render({}, { target: "_self" });
+
+ const link = screen.getByText("With image").closest("a");
+ expect(link).toHaveAttribute("href", "https://example.com/1");
+ expect(link).toHaveAttribute("target", "_self");
+ expect(link.querySelector("img")).toHaveAttribute("src", "https://example.com/1.jpg");
+ expect(screen.getByText("No link").closest("a")).toBeNull();
+ expect(container.querySelectorAll("img")).toHaveLength(1);
+ });
+
+ it("renders the grid layout with image placeholders", () => {
+ useWidgetAPI.mockReturnValue({ data: { items }, error: undefined });
+ const { container } = render({ layout: "grid" });
+
+ expect(container.querySelector(".grid")).not.toBeNull();
+ expect(container.querySelectorAll(".aspect-video")).toHaveLength(2);
+ expect(container.querySelectorAll("img")).toHaveLength(1);
+ });
+
+ it("hides images that fail to load", () => {
+ useWidgetAPI.mockReturnValue({ data: { items }, error: undefined });
+ const { container } = render();
+ const img = container.querySelector("img");
+ img.dispatchEvent(new Event("error"));
+ expect(img.style.visibility).toBe("hidden");
+ });
+});
diff --git a/src/widgets/feed/proxy.js b/src/widgets/feed/proxy.js
new file mode 100644
index 000000000..3d2a95d7c
--- /dev/null
+++ b/src/widgets/feed/proxy.js
@@ -0,0 +1,61 @@
+import cache from "memory-cache";
+
+import { httpUrl, parseFeed } from "./utils";
+
+import getServiceWidget from "utils/config/service-helpers";
+import createLogger from "utils/logger";
+import { sanitizeErrorURL } from "utils/proxy/api-helpers";
+import { httpProxy } from "utils/proxy/http";
+
+const logger = createLogger("feedProxyHandler");
+const CACHE_MS = 10 * 60 * 1000;
+
+export default async function feedProxyHandler(req, res) {
+ const { group, service, index } = req.query;
+ const widget = await getServiceWidget(group, service, index);
+
+ if (!widget?.url) {
+ return res.status(400).json({ error: "Missing feed URL" });
+ }
+
+ const href = httpUrl(widget.url);
+ if (!href) {
+ return res.status(400).json({ error: "Invalid feed URL" });
+ }
+ const url = new URL(href);
+
+ const cacheKey = `feed:${url.href}`;
+ let items = cache.get(cacheKey);
+
+ if (!items) {
+ const [status, , data] = await httpProxy(url, {
+ headers: {
+ "User-Agent": `gethomepage/${process.env.NEXT_PUBLIC_VERSION || "dev"}`,
+ Accept: "application/rss+xml, application/atom+xml, application/xml;q=0.9, text/xml;q=0.9, */*;q=0.8",
+ },
+ });
+
+ if (status !== 200) {
+ logger.debug("HTTP %d retrieving feed %s//%s%s", status, url.protocol, url.host, url.pathname);
+ return res.status(status).json({ error: { message: "HTTP Error", url: sanitizeErrorURL(url) } });
+ }
+
+ try {
+ items = parseFeed(Buffer.from(data).toString());
+ } catch (e) {
+ logger.debug("Error parsing feed %s//%s%s: %s", url.protocol, url.host, url.pathname, e.message);
+ return res.status(500).json({ error: { message: "Invalid feed", url: sanitizeErrorURL(url) } });
+ }
+
+ // only successful fetches are cached
+ cache.put(cacheKey, items, CACHE_MS);
+ }
+
+ const limit = parseInt(widget.maxItems, 10);
+ const maxItems = limit > 0 ? limit : 5;
+ const showImages = widget.images !== false && widget.images !== "false";
+
+ return res.status(200).json({
+ items: items.slice(0, maxItems).map(({ image, ...item }) => (showImages && image ? { ...item, image } : item)),
+ });
+}
diff --git a/src/widgets/feed/proxy.test.js b/src/widgets/feed/proxy.test.js
new file mode 100644
index 000000000..a8d7f1f2b
--- /dev/null
+++ b/src/widgets/feed/proxy.test.js
@@ -0,0 +1,183 @@
+import cache from "memory-cache";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import createMockRes from "test-utils/create-mock-res";
+
+const { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({
+ httpProxy: vi.fn(),
+ getServiceWidget: vi.fn(),
+ logger: {
+ debug: vi.fn(),
+ },
+}));
+
+vi.mock("utils/logger", () => ({
+ default: () => logger,
+}));
+
+vi.mock("utils/config/service-helpers", () => ({
+ default: getServiceWidget,
+}));
+
+vi.mock("utils/proxy/http", () => ({
+ httpProxy,
+}));
+
+import feedProxyHandler from "./proxy";
+
+const items = Array.from(
+ { length: 7 },
+ (_, i) => `- Item ${i}https://example.com/${i}
+
`,
+).join("");
+const feed = `${items}`;
+
+const req = { query: { group: "g", service: "svc", index: "0" } };
+
+describe("widgets/feed/proxy", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ cache.clear();
+ });
+
+ it("returns 400 when the url is missing or invalid", async () => {
+ getServiceWidget.mockResolvedValueOnce({ type: "feed" });
+ let res = createMockRes();
+ await feedProxyHandler(req, res);
+ expect(res.statusCode).toBe(400);
+ expect(res.body).toEqual({ error: "Missing feed URL" });
+
+ getServiceWidget.mockResolvedValueOnce({ type: "feed", url: "nope" });
+ res = createMockRes();
+ await feedProxyHandler(req, res);
+ expect(res.statusCode).toBe(400);
+ expect(res.body).toEqual({ error: "Invalid feed URL" });
+
+ getServiceWidget.mockResolvedValueOnce({ type: "feed", url: "file:///etc/passwd" });
+ res = createMockRes();
+ await feedProxyHandler(req, res);
+ expect(res.statusCode).toBe(400);
+ expect(res.body).toEqual({ error: "Invalid feed URL" });
+ expect(httpProxy).not.toHaveBeenCalled();
+ });
+
+ it("returns the first 5 items by default", async () => {
+ getServiceWidget.mockResolvedValue({ type: "feed", url: "https://example.com/feed.xml" });
+ httpProxy.mockResolvedValueOnce([200, "application/rss+xml", Buffer.from(feed)]);
+
+ const res = createMockRes();
+ await feedProxyHandler(req, res);
+
+ expect(httpProxy.mock.calls[0][0].href).toBe("https://example.com/feed.xml");
+ expect(res.statusCode).toBe(200);
+ expect(res.body.items).toHaveLength(5);
+ expect(res.body.items[0]).toEqual({
+ title: "Item 0",
+ link: "https://example.com/0",
+ date: null,
+ image: "https://example.com/0.jpg",
+ });
+ });
+
+ it.each([-1, 0, "abc"])("falls back to 5 items for maxItems %j", async (maxItems) => {
+ getServiceWidget.mockResolvedValue({ type: "feed", url: "https://example.com/feed.xml", maxItems });
+ httpProxy.mockResolvedValueOnce([200, "application/rss+xml", Buffer.from(feed)]);
+
+ const res = createMockRes();
+ await feedProxyHandler(req, res);
+
+ expect(res.body.items).toHaveLength(5);
+ });
+
+ it("respects maxItems and drops images when disabled", async () => {
+ getServiceWidget.mockResolvedValue({
+ type: "feed",
+ url: "https://example.com/feed.xml",
+ maxItems: "2",
+ images: false,
+ });
+ httpProxy.mockResolvedValueOnce([200, "application/rss+xml", Buffer.from(feed)]);
+
+ const res = createMockRes();
+ await feedProxyHandler(req, res);
+
+ expect(res.body.items).toEqual([
+ { title: "Item 0", link: "https://example.com/0", date: null },
+ { title: "Item 1", link: "https://example.com/1", date: null },
+ ]);
+ });
+
+ it("passes through http errors without leaking the url", async () => {
+ getServiceWidget.mockResolvedValue({ type: "feed", url: "https://example.com/feed.xml?token=secret" });
+ httpProxy.mockResolvedValueOnce([404, "text/html", Buffer.from("Not found")]);
+
+ const res = createMockRes();
+ await feedProxyHandler(req, res);
+
+ expect(res.statusCode).toBe(404);
+ expect(res.body).toEqual({ error: { message: "HTTP Error", url: "example.com (see logs for details)" } });
+ });
+
+ it("never includes any part of the configured url in the response", async () => {
+ getServiceWidget.mockResolvedValue({
+ type: "feed",
+ url: "https://user:hunter2@nas.lan/feeds/s3cr3t/rss.xml?token=abc123",
+ });
+ const relative = `
+ - Pathitem/1
+ - Root/x
+ - Fragment#top
+ `;
+ httpProxy.mockResolvedValueOnce([200, "application/rss+xml", Buffer.from(relative)]);
+
+ const res = createMockRes();
+ await feedProxyHandler(req, res);
+
+ expect(res.body.items.map((item) => item.link)).toEqual([null, null, null]);
+ expect(JSON.stringify(res.body)).not.toMatch(/user|hunter2|nas\.lan|s3cr3t|token|abc123/);
+ });
+
+ it("returns 500 for unparseable feeds", async () => {
+ getServiceWidget.mockResolvedValue({ type: "feed", url: "https://example.com/feed.xml" });
+ httpProxy.mockResolvedValueOnce([200, "text/html", Buffer.from("")]);
+
+ const res = createMockRes();
+ await feedProxyHandler(req, res);
+
+ expect(res.statusCode).toBe(500);
+ expect(res.body).toEqual({ error: { message: "Invalid feed", url: "example.com (see logs for details)" } });
+ });
+
+ it("shares one cached fetch across widgets with different options", async () => {
+ getServiceWidget
+ .mockResolvedValueOnce({ type: "feed", url: "https://example.com/feed.xml", maxItems: 1 })
+ .mockResolvedValueOnce({ type: "feed", url: "https://example.com/feed.xml", images: false });
+ httpProxy.mockResolvedValue([200, "application/rss+xml", Buffer.from(feed)]);
+
+ const first = createMockRes();
+ await feedProxyHandler(req, first);
+ const second = createMockRes();
+ await feedProxyHandler(req, second);
+
+ expect(httpProxy).toHaveBeenCalledTimes(1);
+ expect(first.body.items).toHaveLength(1);
+ expect(second.body.items).toHaveLength(5);
+ expect(second.body.items[0].image).toBeUndefined();
+ });
+
+ it("does not cache failures", async () => {
+ getServiceWidget.mockResolvedValue({ type: "feed", url: "https://example.com/feed.xml" });
+ httpProxy
+ .mockResolvedValueOnce([404, "text/html", Buffer.from("Not found")])
+ .mockResolvedValueOnce([200, "application/rss+xml", Buffer.from(feed)]);
+
+ const failed = createMockRes();
+ await feedProxyHandler(req, failed);
+ const recovered = createMockRes();
+ await feedProxyHandler(req, recovered);
+
+ expect(failed.statusCode).toBe(404);
+ expect(recovered.statusCode).toBe(200);
+ expect(httpProxy).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/src/widgets/feed/utils.js b/src/widgets/feed/utils.js
new file mode 100644
index 000000000..3ef8f5371
--- /dev/null
+++ b/src/widgets/feed/utils.js
@@ -0,0 +1,114 @@
+import sax from "sax";
+import { xml2js } from "xml-js";
+
+const asArray = (value) => (value === undefined || value === null ? [] : [].concat(value));
+
+function getText(node) {
+ const first = asArray(node)[0];
+ if (first === undefined) return "";
+ if (typeof first !== "object") return String(first);
+ return asArray(first._cdata ?? first._text).join("");
+}
+
+export function httpUrl(value, baseUrl) {
+ const trimmed = value?.trim();
+ if (!trimmed) return null;
+ try {
+ const url = new URL(trimmed, baseUrl);
+ return ["http:", "https:"].includes(url.protocol) ? url.href : null;
+ } catch {
+ return null;
+ }
+}
+
+function parseDate(value) {
+ const time = Date.parse(value);
+ return Number.isNaN(time) ? null : new Date(time).toISOString();
+}
+
+const attrs = (node) => node?._attributes ?? {};
+const isImage = (node) => attrs(node).medium === "image" || attrs(node).type?.startsWith("image/");
+
+function findImage(item) {
+ const groups = [item, ...asArray(item["media:group"])];
+ const candidates = [
+ ...groups.flatMap((group) => asArray(group["media:thumbnail"])),
+ ...groups.flatMap((group) => asArray(group["media:content"])).filter(isImage),
+ ...asArray(item.enclosure).filter(isImage),
+ ...asArray(item.link).filter((link) => attrs(link).rel === "enclosure" && isImage(link)),
+ ];
+ const image = candidates.find((node) => attrs(node).url || attrs(node).href);
+ return image ? attrs(image).url || attrs(image).href : null;
+}
+
+const isPixel = ({ width, height }) => ["0", "1"].includes(width) || ["0", "1"].includes(height);
+
+// first usable
in item html using sax tokenizer
+function findHtmlImage(htmls, baseUrl) {
+ for (const html of htmls) {
+ if (!html) continue;
+ let image = null;
+ const parser = sax.parser(false, { lowercase: true });
+ parser.onopentag = ({ name, attributes }) => {
+ if (!image && name === "img" && !isPixel(attributes)) image = httpUrl(attributes.src, baseUrl);
+ };
+ parser.onerror = () => {
+ parser.error = null;
+ parser.resume();
+ };
+ parser.write(html).close();
+ if (image) return image;
+ }
+ return null;
+}
+
+function parseRssItem(item) {
+ const guid = asArray(item.guid)[0];
+ const guidLink = attrs(guid).isPermaLink !== "false" ? getText(guid) : null;
+ return {
+ title: getText(item.title),
+ link: getText(item.link) || guidLink,
+ date: getText(item.pubDate) || getText(item["dc:date"]),
+ image: findImage(item),
+ html: [getText(item["content:encoded"]), getText(item.description)],
+ };
+}
+
+const alternateLink = (node) => asArray(node.link).find((l) => (attrs(l).rel ?? "alternate") === "alternate");
+
+function parseAtomEntry(entry) {
+ const link = alternateLink(entry) ?? asArray(entry.link)[0];
+ return {
+ title: getText(entry.title),
+ link: attrs(link).href,
+ date: getText(entry.published) || getText(entry.updated),
+ image: findImage(entry),
+ html: [getText(entry.content), getText(entry.summary)],
+ };
+}
+
+export function parseFeed(xml) {
+ const doc = xml2js(xml, { compact: true });
+
+ let items;
+ let site;
+ if (doc.rss) {
+ items = asArray(doc.rss.channel?.item).map(parseRssItem);
+ site = getText(doc.rss.channel?.link);
+ } else if (doc.feed) {
+ items = asArray(doc.feed.entry).map(parseAtomEntry);
+ site = attrs(alternateLink(doc.feed)).href;
+ } else throw new Error("Unsupported feed format");
+
+ // resolve against the feed's own site link, never the configured url
+ const baseUrl = httpUrl(site) ?? undefined;
+
+ return items
+ .map((item) => ({
+ title: item.title.trim(),
+ link: httpUrl(item.link, baseUrl),
+ date: parseDate(item.date),
+ image: httpUrl(item.image, baseUrl) ?? findHtmlImage(item.html, baseUrl),
+ }))
+ .filter((item) => item.title);
+}
diff --git a/src/widgets/feed/utils.test.js b/src/widgets/feed/utils.test.js
new file mode 100644
index 000000000..51e8bdcb2
--- /dev/null
+++ b/src/widgets/feed/utils.test.js
@@ -0,0 +1,169 @@
+import { describe, expect, it } from "vitest";
+
+import { httpUrl, parseFeed } from "./utils";
+
+const rss = `
+
+
+ Example
+ https://example.com/
+ -
+
+ https://example.com/one
+ Tue, 22 Sep 2026 10:00:00 GMT
+
+
+ -
+ Enclosure image
+ https://example.com/two
+ 2026-09-21T10:00:00Z
+
+
+ -
+ Audio only
+ javascript:alert(1)
+ abc-123
+
+
+ -
+ No title, skipped
+
+
+`;
+
+const atom = `
+
+ Example
+
+ Fish & chips
+
+
+
+ 2026-09-22T12:00:00Z
+
+
+ YouTube style
+
+ 2026-09-20T12:00:00Z
+ not a date
+
+
+
+
+
+`;
+
+describe("widgets/feed/utils", () => {
+ it("parses rss 2.0 items", () => {
+ expect(parseFeed(rss)).toEqual([
+ {
+ title: "Tom & Jerry",
+ link: "https://example.com/one",
+ date: "2026-09-22T10:00:00.000Z",
+ image: "https://img.example.com/one.jpg",
+ },
+ {
+ title: "Enclosure image",
+ link: "https://example.com/two",
+ date: "2026-09-21T10:00:00.000Z",
+ image: "https://example.com/two.png",
+ },
+ { title: "Audio only", link: null, date: null, image: null },
+ ]);
+ });
+
+ it("parses atom entries", () => {
+ expect(parseFeed(atom)).toEqual([
+ {
+ title: "Fish & chips",
+ link: "https://example.com/one",
+ date: "2026-09-22T12:00:00.000Z",
+ image: "https://example.com/one.jpg",
+ },
+ {
+ title: "YouTube style",
+ link: "https://example.com/two",
+ date: "2026-09-20T12:00:00.000Z",
+ image: "https://example.com/two.jpg",
+ },
+ ]);
+ });
+
+ it("falls back to the first usable image in item html", () => {
+ const xml = `
+ https://example.com/
+ -
+ Content image
+ Hi
+ 

]]>
+
+ -
+ Description image
+ <img src="https://example.com/desc.png"> unclosed <b>
+
+ -
+ Media wins
+
+ ]]>
+
+ -
+ No usable image
+
]]>
+
+ `;
+
+ expect(parseFeed(xml).map((item) => item.image)).toEqual([
+ "https://example.com/big.jpg?w=925&h=925",
+ "https://example.com/desc.png",
+ "https://example.com/thumb.jpg",
+ null,
+ ]);
+ });
+
+ it("finds images in atom html content and summary", () => {
+ const xml = `
+ Content<img src="https://example.com/c.jpg">
+ Summary<img src="https://example.com/s.jpg">
+ `;
+
+ expect(parseFeed(xml).map((item) => item.image)).toEqual([
+ "https://example.com/c.jpg",
+ "https://example.com/s.jpg",
+ ]);
+ });
+
+ it("resolves relative urls against the feed's alternate link, never its self link", () => {
+ const entry = `Relative`;
+ const selfOnly = `
+ ${entry}`;
+ const withSite = `
+
+ ${entry}`;
+
+ expect(parseFeed(selfOnly)[0].link).toBeNull();
+ expect(parseFeed(withSite)[0].link).toBe("https://example.com/posts/1");
+ });
+
+ it("handles single-item and empty feeds", () => {
+ const single = "- Only
";
+ expect(parseFeed(single)).toEqual([{ title: "Only", link: null, date: null, image: null }]);
+ expect(parseFeed("")).toEqual([]);
+ });
+
+ it("rejects unsupported documents", () => {
+ expect(() => parseFeed("nope")).toThrow("Unsupported feed format");
+ expect(() => parseFeed("not xml <")).toThrow();
+ });
+
+ it.each([
+ ["https://a.com/x", undefined, "https://a.com/x"],
+ ["/x", "https://a.com/feed", "https://a.com/x"],
+ ["data:image/png;base64,AAAA", undefined, null],
+ ["not a url", undefined, null],
+ ["", undefined, null],
+ [" \n ", "https://a.com/feed", null],
+ [" /x ", "https://a.com/feed", "https://a.com/x"],
+ ])("httpUrl(%j, %j) is %j", (value, base, expected) => {
+ expect(httpUrl(value, base)).toBe(expected);
+ });
+});
diff --git a/src/widgets/feed/widget.js b/src/widgets/feed/widget.js
new file mode 100644
index 000000000..6b69cc71e
--- /dev/null
+++ b/src/widgets/feed/widget.js
@@ -0,0 +1,8 @@
+import feedProxyHandler from "./proxy";
+
+const widget = {
+ api: "{url}",
+ proxyHandler: feedProxyHandler,
+};
+
+export default widget;
diff --git a/src/widgets/feed/widget.test.js b/src/widgets/feed/widget.test.js
new file mode 100644
index 000000000..ac1c67acc
--- /dev/null
+++ b/src/widgets/feed/widget.test.js
@@ -0,0 +1,12 @@
+import { describe, expect, it } from "vitest";
+
+import { expectWidgetConfigShape } from "test-utils/widget-config";
+
+import widget from "./widget";
+
+describe("feed widget config", () => {
+ it("exports a valid widget config", () => {
+ expectWidgetConfigShape(widget);
+ expect(widget.api).toBe("{url}");
+ });
+});
diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js
index aaa024f8d..5716675d0 100644
--- a/src/widgets/widgets.js
+++ b/src/widgets/widgets.js
@@ -31,6 +31,7 @@ import duplicati from "./duplicati/widget";
import emby from "./emby/widget";
import esphome from "./esphome/widget";
import evcc from "./evcc/widget";
+import feed from "./feed/widget";
import filebrowser from "./filebrowser/widget";
import fileflows from "./fileflows/widget";
import firefly from "./firefly/widget";
@@ -191,6 +192,7 @@ const widgets = {
emby,
esphome,
evcc,
+ feed,
filebrowser,
fileflows,
firefly,