An API you can curl, or open in a browser, to receive Bluesky data as markdown!
1'use client'
2
3import { useState, useCallback, useRef, useEffect } from 'react'
4import s from './page.module.css'
5import FollowPrompt from './components/FollowPrompt'
6
7type ThemeSetting = 'system' | 'dark' | 'light'
8
9const THEME_KEY = 'bsky-md-theme'
10
11function isThemeSetting(value: string | null): value is ThemeSetting {
12 return value === 'system' || value === 'dark' || value === 'light'
13}
14
15function getStoredThemeSetting(): ThemeSetting {
16 if (typeof window === 'undefined') return 'system'
17 try {
18 const stored = localStorage.getItem(THEME_KEY)
19 if (isThemeSetting(stored)) return stored
20 } catch {
21 // no-op
22 }
23 return 'system'
24}
25
26function applyThemeSetting(setting: ThemeSetting) {
27 const root = document.documentElement
28 if (setting === 'system') {
29 root.removeAttribute('data-theme')
30 return
31 }
32 root.setAttribute('data-theme', setting)
33}
34
35// ── URL parser ────────────────────────────────────────────────────────────────
36
37interface Parsed {
38 path: string
39 label: string
40 isPost: boolean
41}
42
43function parseBskyInput(raw: string): Parsed | null {
44 const input = raw.trim()
45 if (!input) return null
46
47 try {
48 const urlStr = /^https?:\/\//i.test(input) ? input : `https://${input}`
49 const url = new URL(urlStr)
50
51 if (['bsky.app', 'www.bsky.app', 'staging.bsky.app'].includes(url.hostname)) {
52 const p = url.pathname.split('/').filter(Boolean)
53
54 if (p.length === 0) return { path: '/trending', label: 'Trending', isPost: false }
55
56 if (p[0] === 'profile' && p[1]) {
57 const h = p[1]
58 if (p.length === 2) return { path: `/profile/${h}`, label: 'Profile', isPost: false }
59 if (p[2] === 'post' && p[3]) return { path: `/profile/${h}/post/${p[3]}`, label: 'Post', isPost: true }
60 if (p[2] === 'feed' && p[3]) return { path: `/profile/${h}/feed/${p[3]}`, label: 'Feed', isPost: false }
61 if (p[2] === 'likes') return { path: `/profile/${h}/likes`, label: 'Likes', isPost: false }
62 if (p[2] === 'followers') return { path: `/profile/${h}/followers`, label: 'Followers', isPost: false }
63 if (p[2] === 'following') return { path: `/profile/${h}/following`, label: 'Following', isPost: false }
64 return { path: `/profile/${h}`, label: 'Profile', isPost: false }
65 }
66
67 if (p[0] === 'hashtag' && p[1])
68 return { path: `/search?q=${encodeURIComponent('#' + p[1])}`, label: 'Hashtag', isPost: false }
69
70 if (p[0] === 'search') {
71 const q = url.searchParams.get('q') ?? ''
72 return { path: `/search?q=${encodeURIComponent(q)}`, label: 'Search', isPost: false }
73 }
74
75 if (p[0] === 'trending')
76 return { path: '/trending', label: 'Trending', isPost: false }
77 }
78 } catch {
79 // not a URL
80 }
81
82 if (input.startsWith('did:'))
83 return { path: `/profile/${input}`, label: 'Profile', isPost: false }
84
85 if (input.startsWith('#'))
86 return { path: `/search?q=${encodeURIComponent(input)}`, label: 'Hashtag', isPost: false }
87
88 if (/^[\w.-]+$/.test(input) && input.includes('.'))
89 return { path: `/profile/${input}`, label: 'Profile', isPost: false }
90
91 return { path: `/search?q=${encodeURIComponent(input)}`, label: 'Search', isPost: false }
92}
93
94function fmtBytes(n: number): string {
95 if (n < 1000) return `${n} chars`
96 return `${(n / 1000).toFixed(1)}k chars`
97}
98
99
100// ── Catalogue ─────────────────────────────────────────────────────────────────
101
102const ENDPOINTS = [
103 { path: '/profile/:handle', desc: 'Bio, stats, avatar/banner', example: '/profile/bsky.app' },
104 { path: '/profile/:handle/posts', desc: 'Recent posts (paginated)', example: '/profile/bsky.app/posts' },
105 { path: '/profile/:handle/post/:rkey', desc: 'Single post with embeds', example: '/profile/bsky.app/post/3lhreomsy5k2x' },
106 { path: '/…/post/:rkey/thread', desc: 'Full self-reply thread', example: '/profile/bsky.app/post/3lhreomsy5k2x/thread' },
107 { path: '/profile/:handle/feed/:rkey', desc: 'Public custom feed', example: '/profile/bsky.app/feed/whats-hot' },
108 { path: '/profile/:handle/likes', desc: 'Posts the user liked', example: '/profile/bsky.app/likes' },
109 { path: '/profile/:handle/followers', desc: 'Follower list', example: '/profile/bsky.app/followers' },
110 { path: '/profile/:handle/following', desc: 'Following list', example: '/profile/bsky.app/following' },
111 { path: '/search?q=:query', desc: 'Full-text post search', example: '/search?q=atproto' },
112 { path: '/links?url=:url', desc: 'Posts linking to a URL/domain', example: '/links?url=theverge.com' },
113 { path: '/trending', desc: 'Trending topics right now', example: '/trending' },
114 { path: '/llms.txt', desc: 'Machine-readable API guide', example: '/llms.txt' },
115]
116
117const QUICK_LINKS = [
118 { label: '/trending', path: '/trending' },
119 { label: '@bsky.app', path: '/profile/bsky.app' },
120 { label: '/feed/whats-hot', path: '/profile/bsky.app/feed/whats-hot' },
121 { label: '#atproto', path: '/search?q=%23atproto' },
122 { label: 'search:tech', path: '/search?q=tech' },
123]
124
125// ── Component ─────────────────────────────────────────────────────────────────
126
127export default function Home() {
128 const [input, setInput] = useState('')
129 const [parsed, setParsed] = useState<Parsed | null>(null)
130 const [theme, setTheme] = useState<ThemeSetting>('system')
131 const [viewMode, setViewMode] = useState<'post' | 'thread'>('thread')
132 const [markdown, setMarkdown] = useState<string | null>(null)
133 const [loading, setLoading] = useState(false)
134 const [error, setError] = useState<string | null>(null)
135 const [copiedUrl, setCopiedUrl] = useState(false)
136 const [copiedMd, setCopiedMd] = useState(false)
137 const [skillMd, setSkillMd] = useState<string | null>(null)
138 const [copiedSkill, setCopiedSkill] = useState(false)
139 const abortRef = useRef<AbortController | null>(null)
140 const resultRef = useRef<HTMLDivElement | null>(null)
141
142 // Live type detection as user types
143 const detected = input.trim() ? parseBskyInput(input) : null
144 const canRun = input.trim().length > 0 && !loading
145
146 const getPath = useCallback((p: Parsed, mode: 'post' | 'thread') => {
147 if (p.isPost && mode === 'thread') return p.path + '/thread'
148 return p.path
149 }, [])
150
151 const fetchMd = useCallback(async (apiPath: string) => {
152 if (abortRef.current) abortRef.current.abort()
153 const ctrl = new AbortController()
154 abortRef.current = ctrl
155 setLoading(true)
156 setError(null)
157 setMarkdown(null)
158 try {
159 const res = await fetch(apiPath, { signal: ctrl.signal })
160 const text = await res.text()
161 if (!res.ok) setError(text)
162 else setMarkdown(text)
163 } catch (e: unknown) {
164 if (e instanceof Error && e.name !== 'AbortError') setError(e.message)
165 } finally {
166 setLoading(false)
167 }
168 }, [])
169
170 const run = useCallback(
171 (p: Parsed, mode: 'post' | 'thread') => {
172 setParsed(p)
173 fetchMd(getPath(p, mode))
174 setTimeout(() => resultRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }), 80)
175 },
176 [fetchMd, getPath],
177 )
178
179 const handleConvert = useCallback(() => {
180 if (loading) return
181 const p = parseBskyInput(input)
182 if (!p) return
183 run(p, viewMode)
184 }, [input, viewMode, run, loading])
185
186 const handleQuick = useCallback(
187 (path: string) => {
188 setInput(path)
189 run({ path, label: 'Quick', isPost: false }, 'post')
190 },
191 [run],
192 )
193
194 const handleViewToggle = useCallback(
195 (mode: 'post' | 'thread') => {
196 setViewMode(mode)
197 if (parsed?.isPost) fetchMd(getPath(parsed, mode))
198 },
199 [parsed, fetchMd, getPath],
200 )
201
202 const copyUrl = useCallback(() => {
203 if (!parsed) return
204 const full = (typeof window !== 'undefined' ? window.location.origin : '') + getPath(parsed, viewMode)
205 navigator.clipboard.writeText(full).then(() => {
206 setCopiedUrl(true)
207 setTimeout(() => setCopiedUrl(false), 2000)
208 })
209 }, [parsed, viewMode, getPath])
210
211 const copyMarkdown = useCallback(() => {
212 if (!markdown) return
213 navigator.clipboard.writeText(markdown).then(() => {
214 setCopiedMd(true)
215 setTimeout(() => setCopiedMd(false), 2000)
216 })
217 }, [markdown])
218
219 const copySkill = useCallback(() => {
220 if (!skillMd) return
221 navigator.clipboard.writeText(skillMd).then(() => {
222 setCopiedSkill(true)
223 setTimeout(() => setCopiedSkill(false), 2000)
224 })
225 }, [skillMd])
226
227 useEffect(() => {
228 fetch('/skill.md').then(r => r.text()).then(setSkillMd).catch(() => {})
229 }, [])
230
231 useEffect(() => {
232 const stored = getStoredThemeSetting()
233 setTheme(stored)
234 applyThemeSetting(stored)
235 }, [])
236
237 const setThemePreference = useCallback((next: ThemeSetting) => {
238 setTheme(next)
239 applyThemeSetting(next)
240 try {
241 localStorage.setItem(THEME_KEY, next)
242 } catch {
243 // no-op
244 }
245 }, [])
246
247 const activePath = parsed ? getPath(parsed, parsed.isPost ? viewMode : 'post') : null
248 const charCount = markdown ? markdown.length : 0
249
250 return (
251 <div className={s.page}>
252 <header className={s.header}>
253 <div className={s.nav}>
254 <a href="/" className={s.logo}>
255 <span className={s.logoMark} aria-hidden="true">></span>
256 bsky.md
257 </a>
258 <div className={s.navRight}>
259 <nav className={s.navLinks}>
260 <a href="/trending">Trending</a>
261 <a href="/llms.txt">llms.txt</a>
262 <a href="/cli">CLI</a>
263 <a href="https://tangled.org/j4ck.xyz/bsky-md" target="_blank" rel="noopener noreferrer">
264 Source
265 </a>
266 </nav>
267 <div className={s.themeSwitch} role="group" aria-label="Theme preference">
268 <button
269 type="button"
270 className={`${s.themeBtn} ${theme === 'system' ? s.themeBtnActive : ''}`}
271 onClick={() => setThemePreference('system')}
272 aria-pressed={theme === 'system'}
273 >
274 Auto
275 </button>
276 <button
277 type="button"
278 className={`${s.themeBtn} ${theme === 'dark' ? s.themeBtnActive : ''}`}
279 onClick={() => setThemePreference('dark')}
280 aria-pressed={theme === 'dark'}
281 >
282 Dark
283 </button>
284 <button
285 type="button"
286 className={`${s.themeBtn} ${theme === 'light' ? s.themeBtnActive : ''}`}
287 onClick={() => setThemePreference('light')}
288 aria-pressed={theme === 'light'}
289 >
290 Light
291 </button>
292 </div>
293 </div>
294 </div>
295 </header>
296
297 <section className={s.hero}>
298 <p className={s.kicker}>Terminal-native Bluesky export</p>
299 <h1 className={s.title}>Bluesky -> Markdown</h1>
300 <p className={s.subtitle}>
301 Paste any profile, post, feed, hashtag, or query and return clean plain-text Markdown ready
302 for copy, curl, or coding agents.
303 </p>
304
305 <label htmlFor="bsky-input" className={s.inputLabel}>
306 Bluesky URL, handle, hashtag, or search query
307 </label>
308 <div className={s.inputWrapper}>
309 <input
310 id="bsky-input"
311 className={s.input}
312 type="text"
313 placeholder="bsky.app/profile/... | post URL | #hashtag | search"
314 value={input}
315 onChange={(e) => setInput(e.target.value)}
316 onKeyDown={(e) => e.key === 'Enter' && handleConvert()}
317 autoFocus
318 spellCheck={false}
319 autoComplete="off"
320 />
321 {detected && <span className={s.detectedBadge}>{detected.label}</span>}
322 <button type="button" className={s.convertBtn} onClick={handleConvert} disabled={!canRun}>
323 {loading ? 'Running' : 'Run'}
324 </button>
325 </div>
326
327 <div className={s.pills}>
328 {QUICK_LINKS.map((ql) => (
329 <button type="button" key={ql.path} className={s.pill} onClick={() => handleQuick(ql.path)}>
330 {ql.label}
331 </button>
332 ))}
333 </div>
334 </section>
335
336 {(loading || markdown !== null || error !== null) && parsed && (
337 <section className={s.resultSection} ref={resultRef}>
338 <div className={s.resultCard}>
339 <div className={s.resultBar}>
340 <span className={s.resultLabel}>{parsed.label}</span>
341 <code className={s.resultUrl}>{activePath}</code>
342 <div className={s.resultActions}>
343 <button
344 type="button"
345 className={`${s.actionBtn} ${copiedUrl ? s.actionBtnSuccess : ''}`}
346 onClick={copyUrl}
347 >
348 {copiedUrl ? '✓ Copied' : 'Copy URL'}
349 </button>
350 <a
351 className={s.actionBtn}
352 href={activePath ?? '#'}
353 target="_blank"
354 rel="noopener noreferrer"
355 >
356 Raw ↗
357 </a>
358 </div>
359 </div>
360
361 {parsed.isPost && (
362 <div className={s.toggle}>
363 <button
364 type="button"
365 className={`${s.toggleBtn} ${viewMode === 'thread' ? s.toggleActive : ''}`}
366 onClick={() => handleViewToggle('thread')}
367 aria-pressed={viewMode === 'thread'}
368 >
369 Full Thread
370 </button>
371 <button
372 type="button"
373 className={`${s.toggleBtn} ${viewMode === 'post' ? s.toggleActive : ''}`}
374 onClick={() => handleViewToggle('post')}
375 aria-pressed={viewMode === 'post'}
376 >
377 Single Post
378 </button>
379 </div>
380 )}
381
382 {!loading && markdown && (
383 <div className={s.previewToolbar}>
384 <span className={s.charCount}>{fmtBytes(charCount)}</span>
385 <button
386 type="button"
387 className={`${s.actionBtn} ${copiedMd ? s.actionBtnSuccess : ''}`}
388 onClick={copyMarkdown}
389 >
390 {copiedMd ? '✓ Copied' : 'Copy Markdown'}
391 </button>
392 </div>
393 )}
394
395 {loading && (
396 <div className={s.previewLoading} role="status" aria-live="polite">
397 <span className={s.spinner} />
398 Fetching...
399 </div>
400 )}
401 {!loading && error && (
402 <pre className={`${s.preview} ${s.previewError}`} role="alert">{error}</pre>
403 )}
404 {!loading && markdown && (
405 <pre className={s.preview}>{markdown}</pre>
406 )}
407 </div>
408 </section>
409 )}
410
411 <div className={s.infoStrip}>
412 <div className={s.infoItem}>
413 <span className={s.infoKey}>Auth</span>
414 <span className={s.infoValue}>No API key needed</span>
415 </div>
416 <div className={s.infoItem}>
417 <span className={s.infoKey}>CORS</span>
418 <span className={s.infoValue}>Open from any origin</span>
419 </div>
420 <div className={s.infoItem}>
421 <span className={s.infoKey}>Cache</span>
422 <span className={s.infoValue}>Fast edge responses</span>
423 </div>
424 <div className={s.infoItem}>
425 <span className={s.infoKey}>Format</span>
426 <span className={s.infoValue}>LLM-safe plain markdown</span>
427 </div>
428 </div>
429
430 <section className={s.terminalSection}>
431 <h2 className={s.sectionTitle}>Terminal workflow</h2>
432 <p className={s.terminalSubtitle}>
433 Use <code>curl</code> directly and pipe output to any terminal renderer, script, or coding agent.
434 </p>
435 <div className={s.terminalBlock}>
436 <div className={s.terminalBar}>
437 <span className={s.terminalDot} />
438 <span className={s.terminalDot} />
439 <span className={s.terminalDot} />
440 </div>
441 <pre className={s.terminalCode}>{[
442 '# Profile',
443 'curl https://bsky.md/profile/j4ck.xyz',
444 '',
445 '# Recent posts',
446 'curl https://bsky.md/profile/mackuba.eu/posts',
447 '',
448 '# Followers',
449 'curl https://bsky.md/profile/j4ck.xyz/followers',
450 '',
451 '# Search',
452 'curl "https://bsky.md/search?q=atproto"',
453 '',
454 '# Trending topics',
455 'curl https://bsky.md/trending',
456 '',
457 '# Custom feed',
458 'curl https://bsky.md/profile/bsky.app/feed/whats-hot',
459 '',
460 '# Agent skill file',
461 'curl -s https://bsky.md/skill.md > ~/.claude/commands/bsky.md',
462 ].join('\n')}</pre>
463 </div>
464 <p className={s.terminalHint}>
465 Tip: requests from terminal clients automatically return Markdown with no additional flags.
466 </p>
467 </section>
468
469 <section className={s.endpointsSection}>
470 <h2 className={s.sectionTitle}>All Endpoints</h2>
471 <div className={s.grid}>
472 {ENDPOINTS.map((ep) => (
473 <a key={ep.path} className={s.card} href={ep.example} target="_blank" rel="noopener noreferrer">
474 <span className={s.cardBadge}>GET</span>
475 <code className={s.cardPath}>{ep.path}</code>
476 <p className={s.cardDesc}>{ep.desc}</p>
477 </a>
478 ))}
479 </div>
480 </section>
481
482 <section className={s.agentSection}>
483 <h2 className={s.sectionTitle}>Add to your coding agent</h2>
484 <div className={s.agentCard}>
485 <div className={s.agentHeader}>
486 <div>
487 <p className={s.agentDesc}>
488 Copy this skill file into Claude, Cursor, Windsurf, Copilot, or any agent that accepts
489 instruction files.
490 </p>
491 <a
492 className={s.agentSkillsLink}
493 href="https://agentskills.io/home"
494 target="_blank"
495 rel="noopener noreferrer"
496 >
497 Learn about agent skills at agentskills.io ↗
498 </a>
499 </div>
500 <button
501 type="button"
502 className={`${s.skillCopyBtn} ${copiedSkill ? s.skillCopyBtnDone : ''}`}
503 onClick={copySkill}
504 disabled={!skillMd}
505 >
506 {copiedSkill ? '✓ Copied' : 'Copy skill.md'}
507 </button>
508 </div>
509 <pre className={s.skillEmbed}>
510 {skillMd ?? 'Loading…'}
511 </pre>
512 <div className={s.agentFooter}>
513 <a href="/skill.md" target="_blank" rel="noopener noreferrer" className={s.agentRawLink}>
514 View raw ↗
515 </a>
516 <span className={s.agentFooterHint}>
517 or <code>curl -s https://bsky.md/skill.md {'>'} ~/.claude/commands/bsky.md</code> for Claude Code
518 </span>
519 </div>
520 </div>
521 </section>
522
523 <footer className={s.footer}>
524 <div className={s.footerLinks}>
525 <a href="/trending">Trending</a>
526 <a href="/llms.txt">llms.txt</a>
527 <a href="/docs">API Docs</a>
528 <a href="/search?q=atproto">Search</a>
529 <a href="https://tangled.org/j4ck.xyz/bsky-md" target="_blank" rel="noopener noreferrer">
530 Source on Tangled
531 </a>
532 </div>
533 <p className={s.footerNote}>Content-Type: text/markdown · bsky.md</p>
534 </footer>
535
536 <FollowPrompt />
537 </div>
538 )
539}