← ui

web-feed

lib

a site's RSS or Atom feed read into items: title, address, date, picture

npx shadcn add @ag/web-feed
Already installed? Preview an update
npx shadcn add @ag/web-feed --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-16
  • New: a site's feed read into items (title, address, date, picture), newest first. parseFeed reads RSS 2.0 and Atom (a blog, a YouTube channel's uploads, a podcast) and runs anywhere; feedOf finds the feed a page names; readFeed fetches one on a server through public-fetch.
Updated 2026-10-16 · release notes

also called rss, atom, feed reader

shelf social & links

uses public-fetch, web-preview-facts

two feeds, read

2013-07-01Do Things that Don't Scale/ds.html
2005-03-01How to Start a Startup/start.html
2023-11-23[1hr Talk] Intro to Large Language Models/watch · picture

the page names its feed at https://example.com/feed.xml

the demo above, verbatim

web-feed.demo.tsx
import { Sample } from "@/app/samples"
import { feedOf, parseFeed } from "@/registry/base-nova/lib/web-feed"

/** A blog's RSS and a channel's Atom, as their servers send them, and the page
 *  that names its feed: parsed here, nothing fetched. */
// #region feeds
const RSS = `<?xml version="1.0"?>
<rss version="2.0"><channel><title>A blog</title>
  <item><title>Do Things that Don&#39;t Scale</title><link>https://paulgraham.com/ds.html</link><pubDate>Mon, 01 Jul 2013 00:00:00 GMT</pubDate></item>
  <item><title><![CDATA[How to Start a Startup]]></title><link>/start.html</link><pubDate>Sat, 01 Mar 2005 00:00:00 GMT</pubDate></item>
</channel></rss>`

const ATOM = `<?xml version="1.0"?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">
  <entry><title>[1hr Talk] Intro to Large Language Models</title>
    <link rel="alternate" href="https://www.youtube.com/watch?v=zjkBMFhNj_g"/>
    <published>2023-11-23T00:00:00+00:00</published>
    <media:group><media:thumbnail url="https://i.ytimg.com/vi/zjkBMFhNj_g/hqdefault.jpg" width="480" height="360"/></media:group>
  </entry>
</feed>`

const PAGE = `<html><head><link rel="alternate" type="application/rss+xml" href="/feed.xml"></head></html>`
// #endregion

export default function Demo() {
  const items = [
    ...parseFeed(RSS, "https://paulgraham.com/rss.html"),
    ...parseFeed(ATOM, "https://www.youtube.com/feeds/videos.xml"),
  ]
  return (
    <Sample name="items" title="two feeds, read" with="feeds">
      <div className="space-y-4">
        <table className="w-full font-mono text-xs">
          <tbody>
            {items.map((item) => (
              <tr
                key={item.url}
                className="border-border border-t first:border-t-0"
              >
                <td className="py-2 pr-4">
                  {item.at?.slice(0, 10) ?? "undated"}
                </td>
                <td className="py-2 pr-4">{item.title}</td>
                <td className="py-2 text-muted-foreground">
                  {new URL(item.url).pathname}
                  {item.image ? " · picture" : ""}
                </td>
              </tr>
            ))}
          </tbody>
        </table>
        <p className="font-mono text-muted-foreground text-xs">
          the page names its feed at {feedOf(PAGE, "https://example.com/blog")}
        </p>
      </div>
    </Sample>
  )
}

the source, every file an install writes

web-feed.ts
// A SITE'S FEED, READ: the latest of what it published, from its RSS 2.0 or Atom
// feed (a blog, a YouTube channel's uploads, a podcast, a changelog). `parseFeed` is
// string work on the XML and nothing else, so it runs anywhere; `feedOf` finds the
// feed a page names (<link rel="alternate">); `readFeed` fetches one, on a server,
// through `publicFetch`, since the address usually comes from a person.
//
// What an item is: a title, where it opens, when, and its picture where the feed
// carries one (media:thumbnail, an enclosure image). Items without a title or an
// address are no items; a date that does not parse leaves the item undated, last.

import { publicFetch } from "@/registry/base-nova/lib/public-fetch"
import { hrefOf } from "@/registry/base-nova/lib/web-preview-facts"

export interface FeedItem {
  title: string
  /** Absolute URL of the item. */
  url: string
  /** When it was published, ISO 8601; absent where the feed does not say. */
  at?: string
  /** Absolute URL of its picture, where the feed carries one. */
  image?: string
}

const ENTITIES: Record<string, string> = {
  amp: "&",
  lt: "<",
  gt: ">",
  quot: '"',
  apos: "'",
  nbsp: " ",
}

function decode(s: string): string {
  return s
    .replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1")
    .replace(/&(#\d+|#x[0-9a-f]+|[a-z]+);/gi, (m, code: string) => {
      if (code[0] === "#") {
        const n =
          code[1] === "x" || code[1] === "X"
            ? Number.parseInt(code.slice(2), 16)
            : Number.parseInt(code.slice(1), 10)
        return Number.isFinite(n) ? String.fromCodePoint(n) : m
      }
      return ENTITIES[code.toLowerCase()] ?? m
    })
    .replace(/<[^>]+>/g, "")
    .replace(/\s+/g, " ")
    .trim()
}

/** The text of the first `<tag>` in `xml` (any namespace prefix), decoded. */
function text(xml: string, tag: string): string | undefined {
  const m = new RegExp(
    `<(?:[\\w-]+:)?${tag}\\b[^>]*>([\\s\\S]*?)</(?:[\\w-]+:)?${tag}>`,
    "i",
  ).exec(xml)
  const v = m?.[1] ? decode(m[1]) : ""
  return v || undefined
}

/** An attribute of the first `<tag …>` matching `where`. */
function attr(
  xml: string,
  tag: string,
  name: string,
  where: (element: string) => boolean = () => true,
): string | undefined {
  for (const m of xml.matchAll(
    new RegExp(`<(?:[\\w-]+:)?${tag}\\b[^>]*>`, "gi"),
  )) {
    if (!where(m[0])) continue
    const v = new RegExp(`\\b${name}=["']([^"']+)["']`, "i").exec(m[0])?.[1]
    if (v) return decode(v)
  }
  return undefined
}

const absolute = (href: string | undefined, base: string) => {
  if (!href) return undefined
  try {
    return new URL(href, base).href
  } catch {
    return undefined
  }
}

const iso = (date: string | undefined) => {
  if (!date) return undefined
  const ms = Date.parse(date)
  return Number.isNaN(ms) ? undefined : new Date(ms).toISOString()
}

/** The items of an RSS 2.0 or Atom feed, newest first. `base` is the feed's own
 *  address, against which relative links are read. */
export function parseFeed(xml: string, base: string): FeedItem[] {
  const atom = /<feed\b/i.test(xml)
  const blocks = xml.match(
    atom ? /<entry\b[\s\S]*?<\/entry>/gi : /<item\b[\s\S]*?<\/item>/gi,
  )
  const items = (blocks ?? []).flatMap((block): FeedItem[] => {
    const title = text(block, "title")
    const url = absolute(
      atom
        ? (attr(
            block,
            "link",
            "href",
            (e) => !/\brel=["'](?!alternate)/i.test(e),
          ) ?? attr(block, "link", "href"))
        : (text(block, "link") ?? attr(block, "link", "href")),
      base,
    )
    if (!title || !url) return []
    const at = iso(
      atom
        ? (text(block, "published") ?? text(block, "updated"))
        : (text(block, "pubDate") ?? text(block, "date")),
    )
    const image = absolute(
      attr(block, "thumbnail", "url") ??
        attr(block, "content", "url", (e) =>
          /medium=["']image|image\//i.test(e),
        ) ??
        attr(block, "enclosure", "url", (e) => /type=["']image\//i.test(e)),
      base,
    )
    return [{ title, url, ...(at && { at }), ...(image && { image }) }]
  })
  return items.sort((a, b) => (b.at ?? "").localeCompare(a.at ?? ""))
}

/** The feed a page names, absolute: its first `<link rel="alternate">` of an RSS
 *  or Atom type; null where it names none. */
export function feedOf(html: string, base: string): string | null {
  const head = /<head\b[\s\S]*?<\/head>/i.exec(html)?.[0] ?? html
  const href = attr(
    head,
    "link",
    "href",
    (e) =>
      /\brel=["'][^"']*alternate/i.test(e) &&
      /\btype=["']application\/(rss|atom)\+xml/i.test(e),
  )
  return absolute(href, base) ?? null
}

/** A feed's items, fetched from the public internet only (`publicFetch`). THROWS
 *  on a feed that does not answer 2xx or holds no items. */
export async function readFeed(url: string): Promise<FeedItem[]> {
  const href = hrefOf(url)
  const res = await publicFetch(href, {
    headers: {
      accept:
        "application/atom+xml, application/rss+xml, application/xml;q=0.9, */*;q=0.5",
    },
  })
  if (!res.ok) throw new Error(`readFeed ${href}: HTTP ${res.status}`)
  const xml = (await res.text()).slice(0, 2 * 1024 * 1024)
  const items = parseFeed(xml, res.url || href)
  if (items.length === 0) throw new Error(`readFeed ${href}: no items`)
  return items
}