Scrolling RSS headlines from the last 6 hours above the prompt

A little inspiration. A lot of good food.
Recipeat turns recipes from photos, websites, and scraps of text into your own collection. It is a Nuxt 4 app with Zitadel login, a hosted model for reading recipes and one small Python service behind an authenticated extraction API.
npm install
npm run dev
Needs Node.js 22.19+. Login and extraction both require a running server; nuxt generate cannot serve them. Storage needs a database — see Database — and without NUXT_DATABASE_URL the server starts and answers 503.
npm run build
node .output/server/index.mjs
npm run test:database # node:test, no database needed
npm run test:recipes # node:test, the validator on the way in
NUXT_DATABASE_URL=... npm run test:database:live # the migration runner, against a real Postgres
npm run test:extraction # node:test, no browser or model needed
npm run build && npm run test:auth # Playwright against a mock OIDC issuer
cd services/recipeat-fetcher && uv run pytest # the fetcher service
nuxt-oidc-auth, with GET /api/me as the worked example of a server-enforced private endpoint.POST /api/extract/text sends text to the model and returns a normalized recipe. It is authenticated, and it stores nothing yet.POST /api/extract/website turns a recipe URL into the same shape through the fetcher service, with no model involved. Also authenticated, also stores nothing.POST /api/extract/photo sends an uploaded photo to the same model the text path uses, which reads the page itself. The image is read and discarded — nothing stores it yet.POST /api/recipes writes an extracted recipe to Postgres, owned by the subject in the session. GET /api/recipes lists a user's collection and GET /api/recipes/{id} reads one; nobody reaches another user's rows.PUT /api/recipes/{id} corrects a version in place; POST /api/recipes/{id}/progressions adds a version to its line and moves the pin to it; POST /api/recipes/{id}/variants branches into a line of its own. Planning has what those words mean.The browser calls the extraction routes and POST /api/recipes: an imported recipe can be added to the collection. The shelf does not read that collection yet, and still shows samples saved in browser local storage. Planning covers what is left.
| Authentication | Zitadel application, secrets, deployment |
| Fetcher | The Python service behind website import |
| Database | Postgres, migrations, and the third container |
| Extraction | The API, the pipeline, the recipe shape |
| API map | Every route, arranged for testing by hand in Postman |
| Planning | What is next, and what is still undecided |
| Design | Colour, type, shape, and how the app moves between places |
| Architecture | The two containers, their networks, and every request path |
Photography comes from Unsplash and fonts from Google Fonts, so both need internet access.
hooks/register.tsx 160 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Item } from '../types'
5
6const FEEDS = [
7 ['BBC', 'https://feeds.bbci.co.uk/news/world/rss.xml'],
8 ['Guardian', 'https://www.theguardian.com/world/rss'],
9 ['NPR', 'https://feeds.npr.org/1001/rss.xml'],
10 ['tagesschau', 'https://www.tagesschau.de/index~rss2.xml'],
11 ['Al Jazeera', 'https://www.aljazeera.com/xml/rss/all.xml'],
12 ['DW', 'https://rss.dw.com/rdf/rss-en-all'],
13 ['Euronews', 'https://www.euronews.com/rss'],
14 ['Sky News', 'https://feeds.skynews.com/feeds/rss/world.xml'],
15 ['CBC', 'https://www.cbc.ca/webfeed/rss/rss-topstories'],
16 ['Global News', 'https://globalnews.ca/feed/'],
17] as const
18const SEP = ' • '
19const TICK_MS = 350
20const REFRESH_MS = 10 * 60 * 1000
21const MAX_AGE_MS = 6 * 60 * 60 * 1000
22const PER_FEED = 4
23
24// Every headline in ticker order; empty until the first fetch lands.
25const items = atom({ plugin: 'news-ticker', key: 'items' } as const, [])
26const offset = atom({ plugin: 'news-ticker', key: 'offset' } as const, 0)
27
28const decode = (s: string) =>
29 s
30 .replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
31 .replace(/&#(x[0-9a-f]+|\d+);/gi, (_, n: string) =>
32 String.fromCodePoint(/^x/i.test(n) ? parseInt(n.slice(1), 16) : Number(n)),
33 )
34 .replace(/"/g, '"').replace(/'/g, "'")
35 .replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&')
36 .replace(/\s+/g, ' ')
37 .trim()
38
39// ponytail: regex RSS parsing (<pubDate>, or <dc:date> for DW's RDF); swap for a real parser if Atom feeds are added
40export const itemsOf = (xml: string) =>
41 [...xml.matchAll(/<item[\s>]([\s\S]*?)<\/item>/g)].map(([, body = '']) => ({
42 title: decode(body.match(/<title>([\s\S]*?)<\/title>/)?.[1] ?? ''),
43 published: Date.parse(body.match(/<(?:pubDate|dc:date)>([\s\S]*?)<\//)?.[1] ?? ''),
44 }))
45
46export const ago = (ms: number) => {
47 const minutes = Math.max(0, Math.round(ms / 60_000))
48 if (minutes < 60) return `${minutes} min ago`
49 const hours = Math.round(minutes / 60)
50 if (hours < 48) return hours === 1 ? '1 hour ago' : `${hours} hours ago`
51 return `${Math.round(hours / 24)} days ago`
52}
53
54// One topic per line, `#` starts a comment. Matches whole words or phrases, any case.
55export const topicsOf = (text: string) =>
56 text
57 .split('\n')
58 .map(line => line.replace(/#.*/, '').trim())
59 .filter(Boolean)
60 .map(t => new RegExp(`(?<![\\p{L}\\p{N}])${t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\p{L}\\p{N}])`, 'iu'))
61
62export const isWanted = (title: string, allow: RegExp[], deny: RegExp[]) =>
63 (allow.length === 0 || allow.some(t => t.test(title))) && !deny.some(t => t.test(title))
64
65// An undated headline can't be judged, so it stays.
66export const isRecent = (published: number, now: number) => Number.isNaN(published) || now - published <= MAX_AGE_MS
67
68// Every headline joined into one loop of text, its age worked out now rather than at fetch time.
69export const stripOf = (list: readonly Item[], now: number) =>
70 list
71 .map(i => `${i.title} [${i.outlet}${i.published === null ? '' : `, ${ago(now - i.published)}`}]${SEP}`)
72 .join('')
73
74let timers: Timer[] = []
75
76// Reads the lists, then starts the scroll and the feed refresh, replacing any earlier run.
77async function start($: EngineInterface) {
78 timers.forEach(t => t.cancel())
79 // Editing either list reloads the plugin, so they're read once per load.
80 const dir = $.plugin.root.replace(/[\\/]\.claude-plugin$/, '')
81 const list = (name: string) => $.fs.read(`${dir}/${name}`).then(topicsOf, () => [])
82 const [allow, deny] = await Promise.all([list('whitelist.txt'), list('blacklist.txt')])
83
84 const refresh = async () => {
85 const now = await $.clock.now()
86 const results = await Promise.all(
87 FEEDS.map(([outlet, url]) =>
88 $.http.fetch(url).then(
89 r =>
90 r.ok
91 ? itemsOf(r.text)
92 .filter(i => i.title && isWanted(i.title, allow, deny) && isRecent(i.published, now))
93 .slice(0, PER_FEED)
94 .map(i => ({
95 title: i.title,
96 outlet,
97 published: Number.isNaN(i.published) ? null : i.published,
98 }))
99 : [],
100 () => [],
101 ),
102 ),
103 )
104 // Interleave feeds so one source doesn't hog a stretch of the ticker.
105 const fresh: Item[] = []
106 for (let i = 0; i < PER_FEED; i++) for (const r of results) {
107 const item = r[i]
108 if (item) fresh.push(item)
109 }
110 if (fresh.length === 0) return
111 await update($, items, () => fresh)
112 }
113
114 const tick = async () => {
115 const list = await read($, items)
116 if (list.length === 0) return
117 const length = stripOf(list, await $.clock.now()).length
118 await update($, offset, o => (o + 1) % length)
119 }
120
121 timers = [
122 $.clock.every(TICK_MS, tick),
123 $.clock.every(REFRESH_MS, refresh),
124 // A timer, not a direct call: session.end aborts its own in-flight fetches after 1.5 s.
125 $.clock.after(0, refresh),
126 ]
127}
128
129export const register: Register = on => {
130 on('session.start', async ($, e, next) => {
131 await start($)
132 return next(e)
133 })
134
135 // A /clear ends the session and starts no new session.start, so start over here.
136 on('session.end', async ($, e, next) => {
137 const result = await next(e)
138 if (e.reason === 'clear') await start($)
139 return result
140 })
141
142 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
143 const list = await read($, items)
144 if (e.props.hasSurvey || list.length === 0) return next(e)
145
146 const { Text } = $.ui.resolve(e)
147 const width = Math.max(20, (e.viewport?.columns ?? 80) - 2)
148 const text = stripOf(list, await $.clock.now())
149 const from = (await read($, offset)) % text.length
150 const loop = text.repeat(Math.ceil((from + width) / text.length) + 1)
151
152 // The theme's "subtle" key: a faint gray that sits close to the background in every theme.
153 return (
154 <Text color="subtle" wrap="truncate-end">
155 {loop.slice(from, from + width)}
156 </Text>
157 )
158 })
159}
160types/index.d.ts 9 lines1// `published` is epoch ms, or null when the feed gave no date.
2export type Item = { title: string; outlet: string; published: number | null }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'news-ticker': { items: Item[]; offset: number }
7 }
8}
9