An API you can curl, or open in a browser, to receive Bluesky data as markdown!
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 539 lines 20 kB view raw
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">&gt;</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 -&gt; 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}