'use client' import { useState, useCallback, useRef, useEffect } from 'react' import s from './page.module.css' import FollowPrompt from './components/FollowPrompt' type ThemeSetting = 'system' | 'dark' | 'light' const THEME_KEY = 'bsky-md-theme' function isThemeSetting(value: string | null): value is ThemeSetting { return value === 'system' || value === 'dark' || value === 'light' } function getStoredThemeSetting(): ThemeSetting { if (typeof window === 'undefined') return 'system' try { const stored = localStorage.getItem(THEME_KEY) if (isThemeSetting(stored)) return stored } catch { // no-op } return 'system' } function applyThemeSetting(setting: ThemeSetting) { const root = document.documentElement if (setting === 'system') { root.removeAttribute('data-theme') return } root.setAttribute('data-theme', setting) } // ── URL parser ──────────────────────────────────────────────────────────────── interface Parsed { path: string label: string isPost: boolean } function parseBskyInput(raw: string): Parsed | null { const input = raw.trim() if (!input) return null try { const urlStr = /^https?:\/\//i.test(input) ? input : `https://${input}` const url = new URL(urlStr) if (['bsky.app', 'www.bsky.app', 'staging.bsky.app'].includes(url.hostname)) { const p = url.pathname.split('/').filter(Boolean) if (p.length === 0) return { path: '/trending', label: 'Trending', isPost: false } if (p[0] === 'profile' && p[1]) { const h = p[1] if (p.length === 2) return { path: `/profile/${h}`, label: 'Profile', isPost: false } if (p[2] === 'post' && p[3]) return { path: `/profile/${h}/post/${p[3]}`, label: 'Post', isPost: true } if (p[2] === 'feed' && p[3]) return { path: `/profile/${h}/feed/${p[3]}`, label: 'Feed', isPost: false } if (p[2] === 'likes') return { path: `/profile/${h}/likes`, label: 'Likes', isPost: false } if (p[2] === 'followers') return { path: `/profile/${h}/followers`, label: 'Followers', isPost: false } if (p[2] === 'following') return { path: `/profile/${h}/following`, label: 'Following', isPost: false } return { path: `/profile/${h}`, label: 'Profile', isPost: false } } if (p[0] === 'hashtag' && p[1]) return { path: `/search?q=${encodeURIComponent('#' + p[1])}`, label: 'Hashtag', isPost: false } if (p[0] === 'search') { const q = url.searchParams.get('q') ?? '' return { path: `/search?q=${encodeURIComponent(q)}`, label: 'Search', isPost: false } } if (p[0] === 'trending') return { path: '/trending', label: 'Trending', isPost: false } } } catch { // not a URL } if (input.startsWith('did:')) return { path: `/profile/${input}`, label: 'Profile', isPost: false } if (input.startsWith('#')) return { path: `/search?q=${encodeURIComponent(input)}`, label: 'Hashtag', isPost: false } if (/^[\w.-]+$/.test(input) && input.includes('.')) return { path: `/profile/${input}`, label: 'Profile', isPost: false } return { path: `/search?q=${encodeURIComponent(input)}`, label: 'Search', isPost: false } } function fmtBytes(n: number): string { if (n < 1000) return `${n} chars` return `${(n / 1000).toFixed(1)}k chars` } // ── Catalogue ───────────────────────────────────────────────────────────────── const ENDPOINTS = [ { path: '/profile/:handle', desc: 'Bio, stats, avatar/banner', example: '/profile/bsky.app' }, { path: '/profile/:handle/posts', desc: 'Recent posts (paginated)', example: '/profile/bsky.app/posts' }, { path: '/profile/:handle/post/:rkey', desc: 'Single post with embeds', example: '/profile/bsky.app/post/3lhreomsy5k2x' }, { path: '/…/post/:rkey/thread', desc: 'Full self-reply thread', example: '/profile/bsky.app/post/3lhreomsy5k2x/thread' }, { path: '/profile/:handle/feed/:rkey', desc: 'Public custom feed', example: '/profile/bsky.app/feed/whats-hot' }, { path: '/profile/:handle/likes', desc: 'Posts the user liked', example: '/profile/bsky.app/likes' }, { path: '/profile/:handle/followers', desc: 'Follower list', example: '/profile/bsky.app/followers' }, { path: '/profile/:handle/following', desc: 'Following list', example: '/profile/bsky.app/following' }, { path: '/search?q=:query', desc: 'Full-text post search', example: '/search?q=atproto' }, { path: '/links?url=:url', desc: 'Posts linking to a URL/domain', example: '/links?url=theverge.com' }, { path: '/trending', desc: 'Trending topics right now', example: '/trending' }, { path: '/llms.txt', desc: 'Machine-readable API guide', example: '/llms.txt' }, ] const QUICK_LINKS = [ { label: '/trending', path: '/trending' }, { label: '@bsky.app', path: '/profile/bsky.app' }, { label: '/feed/whats-hot', path: '/profile/bsky.app/feed/whats-hot' }, { label: '#atproto', path: '/search?q=%23atproto' }, { label: 'search:tech', path: '/search?q=tech' }, ] // ── Component ───────────────────────────────────────────────────────────────── export default function Home() { const [input, setInput] = useState('') const [parsed, setParsed] = useState(null) const [theme, setTheme] = useState('system') const [viewMode, setViewMode] = useState<'post' | 'thread'>('thread') const [markdown, setMarkdown] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [copiedUrl, setCopiedUrl] = useState(false) const [copiedMd, setCopiedMd] = useState(false) const [skillMd, setSkillMd] = useState(null) const [copiedSkill, setCopiedSkill] = useState(false) const abortRef = useRef(null) const resultRef = useRef(null) // Live type detection as user types const detected = input.trim() ? parseBskyInput(input) : null const canRun = input.trim().length > 0 && !loading const getPath = useCallback((p: Parsed, mode: 'post' | 'thread') => { if (p.isPost && mode === 'thread') return p.path + '/thread' return p.path }, []) const fetchMd = useCallback(async (apiPath: string) => { if (abortRef.current) abortRef.current.abort() const ctrl = new AbortController() abortRef.current = ctrl setLoading(true) setError(null) setMarkdown(null) try { const res = await fetch(apiPath, { signal: ctrl.signal }) const text = await res.text() if (!res.ok) setError(text) else setMarkdown(text) } catch (e: unknown) { if (e instanceof Error && e.name !== 'AbortError') setError(e.message) } finally { setLoading(false) } }, []) const run = useCallback( (p: Parsed, mode: 'post' | 'thread') => { setParsed(p) fetchMd(getPath(p, mode)) setTimeout(() => resultRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }), 80) }, [fetchMd, getPath], ) const handleConvert = useCallback(() => { if (loading) return const p = parseBskyInput(input) if (!p) return run(p, viewMode) }, [input, viewMode, run, loading]) const handleQuick = useCallback( (path: string) => { setInput(path) run({ path, label: 'Quick', isPost: false }, 'post') }, [run], ) const handleViewToggle = useCallback( (mode: 'post' | 'thread') => { setViewMode(mode) if (parsed?.isPost) fetchMd(getPath(parsed, mode)) }, [parsed, fetchMd, getPath], ) const copyUrl = useCallback(() => { if (!parsed) return const full = (typeof window !== 'undefined' ? window.location.origin : '') + getPath(parsed, viewMode) navigator.clipboard.writeText(full).then(() => { setCopiedUrl(true) setTimeout(() => setCopiedUrl(false), 2000) }) }, [parsed, viewMode, getPath]) const copyMarkdown = useCallback(() => { if (!markdown) return navigator.clipboard.writeText(markdown).then(() => { setCopiedMd(true) setTimeout(() => setCopiedMd(false), 2000) }) }, [markdown]) const copySkill = useCallback(() => { if (!skillMd) return navigator.clipboard.writeText(skillMd).then(() => { setCopiedSkill(true) setTimeout(() => setCopiedSkill(false), 2000) }) }, [skillMd]) useEffect(() => { fetch('/skill.md').then(r => r.text()).then(setSkillMd).catch(() => {}) }, []) useEffect(() => { const stored = getStoredThemeSetting() setTheme(stored) applyThemeSetting(stored) }, []) const setThemePreference = useCallback((next: ThemeSetting) => { setTheme(next) applyThemeSetting(next) try { localStorage.setItem(THEME_KEY, next) } catch { // no-op } }, []) const activePath = parsed ? getPath(parsed, parsed.isPost ? viewMode : 'post') : null const charCount = markdown ? markdown.length : 0 return (
bsky.md

Terminal-native Bluesky export

Bluesky -> Markdown

Paste any profile, post, feed, hashtag, or query and return clean plain-text Markdown ready for copy, curl, or coding agents.

setInput(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleConvert()} autoFocus spellCheck={false} autoComplete="off" /> {detected && {detected.label}}
{QUICK_LINKS.map((ql) => ( ))}
{(loading || markdown !== null || error !== null) && parsed && (
{parsed.label} {activePath}
Raw ↗
{parsed.isPost && (
)} {!loading && markdown && (
{fmtBytes(charCount)}
)} {loading && (
Fetching...
)} {!loading && error && (
{error}
)} {!loading && markdown && (
{markdown}
)}
)}
Auth No API key needed
CORS Open from any origin
Cache Fast edge responses
Format LLM-safe plain markdown

Terminal workflow

Use curl directly and pipe output to any terminal renderer, script, or coding agent.

{[
            '# Profile',
            'curl https://bsky.md/profile/j4ck.xyz',
            '',
            '# Recent posts',
            'curl https://bsky.md/profile/mackuba.eu/posts',
            '',
            '# Followers',
            'curl https://bsky.md/profile/j4ck.xyz/followers',
            '',
            '# Search',
            'curl "https://bsky.md/search?q=atproto"',
            '',
            '# Trending topics',
            'curl https://bsky.md/trending',
            '',
            '# Custom feed',
            'curl https://bsky.md/profile/bsky.app/feed/whats-hot',
            '',
            '# Agent skill file',
            'curl -s https://bsky.md/skill.md > ~/.claude/commands/bsky.md',
          ].join('\n')}

Tip: requests from terminal clients automatically return Markdown with no additional flags.

All Endpoints

{ENDPOINTS.map((ep) => ( GET {ep.path}

{ep.desc}

))}

Add to your coding agent

Copy this skill file into Claude, Cursor, Windsurf, Copilot, or any agent that accepts instruction files.

Learn about agent skills at agentskills.io ↗
            {skillMd ?? 'Loading…'}
          
View raw ↗ or curl -s https://bsky.md/skill.md {'>'} ~/.claude/commands/bsky.md for Claude Code
) }