
Migrating this site to EmDash
This site has changed CMS more times than I can count since 2003. This time I moved to EmDash on Cloudflare, and the reason is that most of my building now happens alongside an AI agent.
Read more
I wanted a dead-simple tool: paste a URL, get a clean Markdown summary streamed into the page. No page reloads, no React, no client-side state library. Just Astro on the server, Datastar for reactivity, and the Vercel AI SDK for the model call.
Here's how the pieces fit together.
There are two files:
That's the whole thing. No client framework, no build-time hydration island, no fetch glue code.
The page declares its state up front as Datastar signals, binds an input to url, and points a button at the action endpoint.
---
import Layout from '@/layouts/Layout.astro'
const initialSignals = JSON.stringify({
url: '',
_loading: false,
_error: '',
_streamChunk: '',
})
---
<Layout title="TLDR">
<section class="max-w-3xl mx-auto p-4" data-signals__ifmissing={initialSignals}>
<h1 class="text-3xl font-bold mb-2">TLDR</h1>
<p class="opacity-70 mb-6">Paste a URL and get an AI-generated summary.</p>
<input
type="text"
class="input input-bordered w-full mb-3"
placeholder="https://example.com/article"
data-bind="url"
/>
<button
class="btn btn-primary"
data-on:click="$_loading = true; $_error = ''; @get('/summarize')"
data-attr:disabled="$_loading || !$url.trim()">
<span data-show="!$_loading">Summarize</span>
<span data-show="$_loading">Summarizing…</span>
</button>
<div data-show="$_error" class="alert alert-error mt-4">
<span data-text="$_error"></span>
</div>
<div
id="summary"
class="prose max-w-none mt-6"
data-on-signal-patch-filter="{include: /^_streamChunk$/}"
data-on-signal-patch="window.__appendChunk?.(el, patch._streamChunk)">
</div>
</section>
</Layout>
<script>
import { default_renderer, parser, parser_write, parser_end } from 'streaming-markdown'
let smd: any = null
window.__appendChunk = (el: HTMLElement, chunk: string | undefined) => {
if (chunk == null) return
if (chunk === '__START__') { el.replaceChildren(); smd = parser(default_renderer(el)); return }
if (chunk === '__END__') { if (smd) parser_end(smd); smd = null; return }
if (smd) parser_write(smd, chunk)
}
</script>A few things worth pointing out:
url signal._streamChunk. We hand each chunk to `streaming-markdown`, which renders Markdown incrementally as tokens arrive — no flicker, no full re-parse on every chunk.The API route is where Datastar meets the Vercel AI SDK. Datastar's SDK gives us a ServerSentEventGenerator that handles the SSE protocol; the AI SDK's streamText gives us an async iterator of text deltas. We just bridge the two.
We also need a tiny extract helper that fetches the page and pulls out the readable text. Cheerio is enough for the 80% case — strip the chrome, prefer <article>/<main>, fall back to <body>.
// src/pages/summarize.ts
import type { APIRoute } from 'astro'
import { ServerSentEventGenerator } from '@starfederation/datastar-sdk/web'
import { streamText } from 'ai'
import { openai } from '@ai-sdk/openai'
import * as cheerio from 'cheerio'
async function extract(url: string): Promise<{ title: string; text: string }> {
const res = await fetch(url, {
headers: { 'user-agent': 'Mozilla/5.0 (compatible; TLDRBot/1.0)' },
signal: AbortSignal.timeout(15_000),
})
if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status}`)
const html = await res.text()
const $ = cheerio.load(html)
// Strip chrome and non-content elements
$('script, style, nav, header, footer, aside, noscript, iframe, form, [role="navigation"], [role="banner"], [role="complementary"]').remove()
const title = $('meta[property="og:title"]').attr('content')?.trim()
|| $('title').text().trim()
|| $('h1').first().text().trim()
|| url
// Prefer semantic main-content containers, fall back to body
const candidates = ['article', 'main', '[role="main"]', '.post-content', '.entry-content', '.article-content']
let node = $('body')
for (const sel of candidates) {
const el = $(sel).first()
if (el.length && el.text().trim().length > 200) { node = el; break }
}
const text = node.text().replace(/\s+/g, ' ').trim().slice(0, 20_000)
if (!text) throw new Error('No readable content found at URL.')
return { title, text }
}
export const GET: APIRoute = async ({ request }) => {
const reader = await ServerSentEventGenerator.readSignals(request)
if (!reader.success) return new Response('Bad request', { status: 400 })
const url = ((reader.signals as any).url || '').trim()
if (!url) {
return ServerSentEventGenerator.stream(async (stream) => {
stream.patchSignals(JSON.stringify({ _loading: false, _error: 'Please enter a URL.' }))
})
}
return ServerSentEventGenerator.stream(async (stream) => {
try {
const article = await extract(url)
stream.patchSignals(JSON.stringify({ _streamChunk: '__START__' }))
const response = streamText({
model: openai('gpt-5'),
system: 'You are a concise summarizer. Output clean Markdown.',
prompt: `Summarize the following article in ~300 words:\n\n${article.text}`,
abortSignal: AbortSignal.timeout(120_000),
})
let pending = ''
let i = 0
for await (const delta of response.textStream) {
pending += delta
if (++i % 5 === 0 || delta.includes('\n')) {
stream.patchSignals(JSON.stringify({ _streamChunk: pending }))
pending = ''
}
}
if (pending) stream.patchSignals(JSON.stringify({ _streamChunk: pending }))
stream.patchSignals(JSON.stringify({ _streamChunk: '__END__' }))
} catch (err: any) {
stream.patchSignals(JSON.stringify({ _error: err.message || 'Something went wrong.' }))
} finally {
stream.patchSignals(JSON.stringify({ _loading: false }))
stream.close()
}
}, { keepalive: true })
}The interesting bits:
<article>/<main>, cap the text at 20k chars so we don't blow the model's context. For sites that need real readability heuristics you'd reach for Mozilla's Readability, but this handles most blogs and news sites just fine.url server-side without writing any form-handling code.__START__ and __END__ let the client reset and finalize the streaming Markdown parser cleanly.I've built variations of this with React + a custom useChat hook, with htmx, and with raw EventSource. The Astro + Datastar + Vercel AI SDK version is the one I keep coming back to, because:
@get + patchSignals means I never write a fetch call or an EventSource listener. The server pushes signal updates and the DOM reacts.openai('gpt-4o') for anthropic('claude-sonnet-4-6') is a one-line change. The streaming interface stays identical.The whole thing is maybe 200 lines of code across both files. No state management library, no client router, no API client. Just a form, an SSE stream, and a Markdown renderer — exactly the amount of machinery the problem deserves.

This site has changed CMS more times than I can count since 2003. This time I moved to EmDash on Cloudflare, and the reason is that most of my building now happens alongside an AI agent.
Read more
I wanted a simple dashboard that showed me events as they happened, so I wrote a quick little Astro app. It grew into Get Butters: a real-time event feed with a REST API, a CLI, insight cards and push alerts.
Read more
Once four agents can edit the same repo, architecture stops being the hard problem and coordination starts. The five roles I run and what each one is forbidden to touch, why every handoff goes through a file, a worktree script so agents stop racing, and the failure modes worth naming.
Read more