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

Configure Feed

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

Add full API, interactive homepage with dark mode, Tangled icon

- 13 API endpoints: profile, posts, post, thread, feed, likes,
followers, following, search, trending + docs + llms.txt
- Homepage: URL converter, live type detection badge, copy URL,
copy markdown button, char count, post/thread toggle
- Auto light/dark mode via CSS prefers-color-scheme
- Shimmer gradient title, slide-in result animation, hover lifts
- SVG icon (Tangled logo), custom favicon
- Feed support: custom Bluesky feeds via /profile/:handle/feed/:rkey
- Trending: live topics from app.bsky.unspecced.getTrendingTopics
- Video embed rendering, follower/following/likes pages
- Search via api.bsky.app (public.api.bsky.app blocks search)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

jack c23c0e0d 373e92f0

+2736 -202
+179
app/docs/route.ts
··· 1 + import { type NextRequest } from 'next/server' 2 + import { markdownResponse, baseUrl } from '@/lib/respond' 3 + 4 + export async function GET(req: NextRequest) { 5 + const base = baseUrl(req) 6 + 7 + const md = `# Bluesky Markdown API 8 + 9 + Returns public Bluesky data as plain Markdown. No authentication or API key required. 10 + All responses use \`Content-Type: text/markdown; charset=utf-8\` and open CORS headers. 11 + 12 + **Machine-readable guide for agents:** [${base}/llms.txt](${base}/llms.txt) 13 + 14 + --- 15 + 16 + ## Endpoints 17 + 18 + ### Profile 19 + 20 + \`\`\` 21 + GET ${base}/profile/:handle 22 + \`\`\` 23 + 24 + Returns a user's display name, bio, avatar/banner image URLs, follower counts, and links to their content. 25 + 26 + **Example:** [${base}/profile/bsky.app](${base}/profile/bsky.app) 27 + 28 + --- 29 + 30 + ### Posts 31 + 32 + \`\`\` 33 + GET ${base}/profile/:handle/posts 34 + GET ${base}/profile/:handle/posts?cursor=<cursor>&limit=<1-100> 35 + \`\`\` 36 + 37 + Paginated list of original posts (replies excluded). Each post includes body text, images, link cards, quote posts, and engagement stats. 38 + 39 + **Example:** [${base}/profile/bsky.app/posts](${base}/profile/bsky.app/posts) 40 + 41 + --- 42 + 43 + ### Single Post 44 + 45 + \`\`\` 46 + GET ${base}/profile/:handle/post/:rkey 47 + \`\`\` 48 + 49 + A single post with full embed rendering: images (alt text + CDN URL), video thumbnails, external link cards, and quoted posts as blockquotes. 50 + 51 + **Example:** [${base}/profile/bsky.app/post/3lhreomsy5k2x](${base}/profile/bsky.app/post/3lhreomsy5k2x) 52 + 53 + --- 54 + 55 + ### Thread 56 + 57 + \`\`\` 58 + GET ${base}/profile/:handle/post/:rkey/thread 59 + \`\`\` 60 + 61 + The full thread starting from the given post: root post followed by all self-replies from the same author, in chronological order. Ideal for reading "tweetstorm"-style threads. 62 + 63 + **Example:** [${base}/profile/bsky.app/post/3lhreomsy5k2x/thread](${base}/profile/bsky.app/post/3lhreomsy5k2x/thread) 64 + 65 + --- 66 + 67 + ### Likes 68 + 69 + \`\`\` 70 + GET ${base}/profile/:handle/likes 71 + GET ${base}/profile/:handle/likes?cursor=<cursor>&limit=<1-100> 72 + \`\`\` 73 + 74 + Paginated list of posts liked by the user, rendered as full post blocks. 75 + 76 + **Example:** [${base}/profile/bsky.app/likes](${base}/profile/bsky.app/likes) 77 + 78 + --- 79 + 80 + ### Followers 81 + 82 + \`\`\` 83 + GET ${base}/profile/:handle/followers 84 + GET ${base}/profile/:handle/followers?cursor=<cursor>&limit=<1-100> 85 + \`\`\` 86 + 87 + Paginated list of accounts that follow this user, with display name, handle, and bio excerpt. 88 + 89 + **Example:** [${base}/profile/bsky.app/followers](${base}/profile/bsky.app/followers) 90 + 91 + --- 92 + 93 + ### Following 94 + 95 + \`\`\` 96 + GET ${base}/profile/:handle/following 97 + GET ${base}/profile/:handle/following?cursor=<cursor>&limit=<1-100> 98 + \`\`\` 99 + 100 + Paginated list of accounts this user follows. 101 + 102 + **Example:** [${base}/profile/bsky.app/following](${base}/profile/bsky.app/following) 103 + 104 + --- 105 + 106 + ### Search 107 + 108 + \`\`\` 109 + GET ${base}/search?q=<query> 110 + GET ${base}/search?q=<query>&cursor=<cursor>&limit=<1-100> 111 + \`\`\` 112 + 113 + Full-text search across all public Bluesky posts. Supports quoted phrases and boolean operators. 114 + 115 + **Example:** [${base}/search?q=atproto](${base}/search?q=atproto) 116 + **Example:** [${base}/search?q="open social web"](${base}/search?q=%22open+social+web%22) 117 + 118 + --- 119 + 120 + ## Embed types 121 + 122 + | Type | Rendered as | 123 + |------|-------------| 124 + | Images | \`![alt](url)\` with alt text line + raw CDN URL | 125 + | External link | Bold linked title, blockquote description, thumbnail | 126 + | Quote post | Markdown blockquote with author, date, body | 127 + | Video | Thumbnail image + HLS playlist link | 128 + 129 + --- 130 + 131 + ## Rich text 132 + 133 + Mentions, URLs, and hashtags in post bodies are converted to Markdown links pointing to \`bsky.app\`. 134 + 135 + --- 136 + 137 + ## Parameters 138 + 139 + - **:handle** — any Bluesky handle (\`user.bsky.social\`, custom domain like \`j4ck.xyz\`) or DID (\`did:plc:...\`) 140 + - **:rkey** — the record key: last segment of a Bluesky post URL 141 + - **cursor** — opaque pagination token returned in previous response 142 + - **limit** — integer 1–100 (defaults vary per endpoint) 143 + 144 + --- 145 + 146 + ## Caching & CORS 147 + 148 + - Responses are edge-cached for **60 seconds**, stale-while-revalidate for **5 minutes** 149 + - \`Access-Control-Allow-Origin: *\` — usable from any browser or tool 150 + - \`OPTIONS\` preflight → \`204 No Content\` 151 + 152 + --- 153 + 154 + ## Error responses 155 + 156 + | Status | Meaning | 157 + |--------|---------| 158 + | 400 | Bad request (e.g. missing \`?q=\` on /search) | 159 + | 404 | Handle or post not found | 160 + | 500 | Upstream Bluesky API error | 161 + 162 + Errors return \`Content-Type: text/plain\` with a message body. 163 + 164 + --- 165 + 166 + ## Use cases 167 + 168 + - LLM context windows — clean plain text, no HTML noise 169 + - Obsidian plugins / Dataview queries 170 + - RSS readers and note-taking apps 171 + - Any tool that can fetch a URL and render Markdown 172 + 173 + --- 174 + 175 + *Source: [github.com/jackgibsondev/bsky-markdown-api](https://github.com/jackgibsondev/bsky-markdown-api)* 176 + ` 177 + 178 + return markdownResponse(md) 179 + }
app/favicon.ico

This is a binary file and will not be displayed.

+43 -26
app/globals.css
··· 1 + *, *::before, *::after { 2 + box-sizing: border-box; 3 + margin: 0; 4 + padding: 0; 5 + } 6 + 1 7 :root { 2 - --background: #ffffff; 3 - --foreground: #171717; 8 + --blue: #0085ff; 9 + --blue-dark: #006ad4; 10 + --blue-pale: #eff6ff; 11 + --text: #0f172a; 12 + --text-2: #475569; 13 + --text-3: #94a3b8; 14 + --bg: #ffffff; 15 + --bg-2: #f8fafc; 16 + --border: #e2e8f0; 17 + --radius: 12px; 18 + --mono: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; 19 + color-scheme: light dark; 4 20 } 5 21 6 22 @media (prefers-color-scheme: dark) { 7 23 :root { 8 - --background: #0a0a0a; 9 - --foreground: #ededed; 24 + --blue: #4da6ff; 25 + --blue-dark: #79bbff; 26 + --blue-pale: #0c1e3a; 27 + --text: #f1f5f9; 28 + --text-2: #94a3b8; 29 + --text-3: #475569; 30 + --bg: #0d1117; 31 + --bg-2: #161b22; 32 + --border: #30363d; 10 33 } 11 34 } 12 35 13 - html, 14 - body { 15 - max-width: 100vw; 16 - overflow-x: hidden; 17 - } 18 - 19 - body { 20 - color: var(--foreground); 21 - background: var(--background); 22 - font-family: Arial, Helvetica, sans-serif; 36 + html { 37 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif; 38 + font-size: 16px; 39 + color: var(--text); 40 + background: var(--bg); 23 41 -webkit-font-smoothing: antialiased; 24 - -moz-osx-font-smoothing: grayscale; 25 - } 26 - 27 - * { 28 - box-sizing: border-box; 29 - padding: 0; 30 - margin: 0; 31 42 } 32 43 33 44 a { 34 - color: inherit; 45 + color: var(--blue); 35 46 text-decoration: none; 36 47 } 48 + a:hover { 49 + text-decoration: underline; 50 + } 37 51 38 - @media (prefers-color-scheme: dark) { 39 - html { 40 - color-scheme: dark; 41 - } 52 + button { 53 + font-family: inherit; 54 + } 55 + 56 + ::selection { 57 + background: #bfdbfe; 58 + color: #0f172a; 42 59 }
+10 -25
app/layout.tsx
··· 1 - import type { Metadata } from "next"; 2 - import { Geist, Geist_Mono } from "next/font/google"; 3 - import "./globals.css"; 4 - 5 - const geistSans = Geist({ 6 - variable: "--font-geist-sans", 7 - subsets: ["latin"], 8 - }); 9 - 10 - const geistMono = Geist_Mono({ 11 - variable: "--font-geist-mono", 12 - subsets: ["latin"], 13 - }); 1 + import type { Metadata } from 'next' 2 + import './globals.css' 14 3 15 4 export const metadata: Metadata = { 16 - title: "Create Next App", 17 - description: "Generated by create next app", 18 - }; 5 + title: 'bsky.md — Bluesky as Markdown', 6 + description: 7 + 'Fetch any public Bluesky profile, post, feed, or search as clean Markdown. No auth, no API key.', 8 + icons: { icon: '/icon.svg' }, 9 + } 19 10 20 - export default function RootLayout({ 21 - children, 22 - }: Readonly<{ 23 - children: React.ReactNode; 24 - }>) { 11 + export default function RootLayout({ children }: { children: React.ReactNode }) { 25 12 return ( 26 13 <html lang="en"> 27 - <body className={`${geistSans.variable} ${geistMono.variable}`}> 28 - {children} 29 - </body> 14 + <body>{children}</body> 30 15 </html> 31 - ); 16 + ) 32 17 }
+184
app/llms.txt/route.ts
··· 1 + import { type NextRequest } from 'next/server' 2 + import { baseUrl } from '@/lib/respond' 3 + 4 + export async function GET(req: NextRequest) { 5 + const base = baseUrl(req) 6 + 7 + const txt = `# Bluesky Markdown API 8 + 9 + > Fetch public Bluesky data as clean Markdown. No auth. No API key. Just HTTP GET. 10 + 11 + Base URL: ${base} 12 + 13 + All responses: 14 + - Content-Type: text/markdown; charset=utf-8 15 + - Access-Control-Allow-Origin: * (open CORS) 16 + - Cached for 60 s, stale-while-revalidate 300 s 17 + 18 + --- 19 + 20 + ## Endpoints 21 + 22 + ### GET / 23 + Returns this API's documentation as Markdown. 24 + 25 + ### GET /llms.txt 26 + Returns this machine-readable guide (plain text, no markdown rendering needed). 27 + 28 + --- 29 + 30 + ### GET /profile/:handle 31 + Returns a user profile. 32 + 33 + Parameters: 34 + - handle — Bluesky handle (e.g. bsky.app, j4ck.xyz, user.bsky.social) or DID (did:plc:...) 35 + 36 + Response fields: 37 + - Display name, handle, bio/description 38 + - Avatar image URL and banner image URL 39 + - Follower count, following count, post count 40 + - Links to posts, followers, following via this API 41 + 42 + Example: ${base}/profile/bsky.app 43 + 44 + --- 45 + 46 + ### GET /profile/:handle/posts 47 + Returns a paginated list of a user's posts (no replies). 48 + 49 + Query parameters: 50 + - cursor — pagination cursor from previous response (optional) 51 + - limit — number of posts, 1–100, default 20 52 + 53 + Response: list of posts with text, images, link cards, quote posts, engagement stats, and pagination link. 54 + 55 + Example: ${base}/profile/bsky.app/posts 56 + Example (paginated): ${base}/profile/bsky.app/posts?cursor=CURSOR&limit=50 57 + 58 + --- 59 + 60 + ### GET /profile/:handle/post/:rkey 61 + Returns a single post. 62 + 63 + Parameters: 64 + - handle — author's handle (must match the post's author, not a reposter) 65 + - rkey — record key; the final path segment of a Bluesky post URL 66 + 67 + Response: post body with rich text, images (with alt text + full CDN URLs), video thumbnails, external link cards, and quote posts as blockquotes. 68 + 69 + Example: ${base}/profile/bsky.app/post/3lhreomsy5k2x 70 + 71 + --- 72 + 73 + ### GET /profile/:handle/post/:rkey/thread 74 + Returns the full thread: the given post plus all replies from the same author, in chronological order. 75 + 76 + Useful for reading "tweetstorm"-style threads as a single document. 77 + 78 + Example: ${base}/profile/bsky.app/post/3lhreomsy5k2x/thread 79 + 80 + --- 81 + 82 + ### GET /profile/:handle/likes 83 + Returns a paginated list of posts liked by the user. 84 + 85 + Query parameters: 86 + - cursor — pagination cursor (optional) 87 + - limit — 1–100, default 20 88 + 89 + Example: ${base}/profile/bsky.app/likes 90 + 91 + --- 92 + 93 + ### GET /profile/:handle/followers 94 + Returns a paginated list of users who follow :handle. 95 + 96 + Query parameters: 97 + - cursor — pagination cursor (optional) 98 + - limit — 1–100, default 50 99 + 100 + Response: list of accounts with display name, handle, and first line of bio. 101 + 102 + Example: ${base}/profile/bsky.app/followers 103 + 104 + --- 105 + 106 + ### GET /profile/:handle/following 107 + Returns a paginated list of users that :handle follows. 108 + 109 + Query parameters: 110 + - cursor — pagination cursor (optional) 111 + - limit — 1–100, default 50 112 + 113 + Example: ${base}/profile/bsky.app/following 114 + 115 + --- 116 + 117 + ### GET /search?q=:query 118 + Full-text search across all public Bluesky posts. 119 + 120 + Query parameters: 121 + - q — search query (required); supports quoted phrases and boolean operators 122 + - cursor — pagination cursor (optional) 123 + - limit — 1–100, default 20 124 + 125 + Response: matching posts with author, text, embeds, engagement stats, and pagination. 126 + 127 + Example: ${base}/search?q=atproto 128 + Example: ${base}/search?q="open social web"&limit=10 129 + 130 + --- 131 + 132 + ## Embed types returned in posts 133 + 134 + - Images — inline Markdown image syntax with alt text + raw CDN URL 135 + - External link — bold linked title, blockquote description, thumbnail image 136 + - Quote post — rendered as a Markdown blockquote with author, date, and body 137 + - Video — thumbnail image + "Watch (HLS)" link to playlist URL 138 + 139 + --- 140 + 141 + ## Rich text 142 + 143 + Mentions (@handle), URLs, and hashtags (#tag) in post bodies are converted to 144 + Markdown links pointing to bsky.app. 145 + 146 + --- 147 + 148 + ## Pagination 149 + 150 + When more results exist, the response contains a "Next page →" link at the 151 + bottom with the cursor pre-encoded. You can also extract the cursor from that 152 + URL's ?cursor= parameter and pass it to subsequent requests. 153 + 154 + --- 155 + 156 + ## Error handling 157 + 158 + - 404 — handle or post not found 159 + - 400 — missing required parameter (e.g. no ?q= on /search) 160 + - 500 — upstream Bluesky API error (message included in plain text body) 161 + 162 + Error responses use Content-Type: text/plain. 163 + 164 + --- 165 + 166 + ## Notes for LLM agents 167 + 168 + - Fetch ${base}/profile/:handle to get a user overview and a link to their posts. 169 + - Fetch ${base}/profile/:handle/posts to read recent posts; follow "Next page →" for more. 170 + - Fetch ${base}/profile/:handle/post/:rkey/thread to get a complete multi-post thread. 171 + - Fetch ${base}/search?q=TOPIC to discover posts about a topic without knowing any handles. 172 + - All responses are plain Markdown — strip formatting or feed directly into context windows. 173 + - No rate limiting is imposed by this API, but Bluesky's public API may throttle heavy use. 174 + ` 175 + 176 + return new Response(txt, { 177 + status: 200, 178 + headers: { 179 + 'Content-Type': 'text/plain; charset=utf-8', 180 + 'Access-Control-Allow-Origin': '*', 181 + 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400', 182 + }, 183 + }) 184 + }
+586 -88
app/page.module.css
··· 1 + /* ── Animations ───────────────────────────────────── */ 2 + @keyframes slideDown { 3 + from { opacity: 0; transform: translateY(-10px); } 4 + to { opacity: 1; transform: translateY(0); } 5 + } 6 + 7 + @keyframes fadeIn { 8 + from { opacity: 0; } 9 + to { opacity: 1; } 10 + } 11 + 12 + @keyframes shimmer { 13 + 0% { background-position: 0% center; } 14 + 100% { background-position: 300% center; } 15 + } 16 + 17 + @keyframes spin { 18 + to { transform: rotate(360deg); } 19 + } 20 + 21 + @keyframes pop { 22 + 0% { transform: scale(1); } 23 + 50% { transform: scale(0.94); } 24 + 100% { transform: scale(1); } 25 + } 26 + 27 + /* ── Layout ───────────────────────────────────────── */ 1 28 .page { 2 - --background: #fafafa; 3 - --foreground: #fff; 29 + min-height: 100vh; 30 + display: flex; 31 + flex-direction: column; 32 + } 4 33 5 - --text-primary: #000; 6 - --text-secondary: #666; 34 + /* ── Header ───────────────────────────────────────── */ 35 + .header { 36 + position: sticky; 37 + top: 0; 38 + z-index: 10; 39 + background: rgba(255, 255, 255, 0.85); 40 + backdrop-filter: blur(14px); 41 + -webkit-backdrop-filter: blur(14px); 42 + border-bottom: 1px solid var(--border); 43 + transition: background 0.2s; 44 + } 7 45 8 - --button-primary-hover: #383838; 9 - --button-secondary-hover: #f2f2f2; 10 - --button-secondary-border: #ebebeb; 46 + @media (prefers-color-scheme: dark) { 47 + .header { 48 + background: rgba(13, 17, 23, 0.85); 49 + } 50 + } 11 51 52 + .nav { 53 + max-width: 900px; 54 + margin: 0 auto; 55 + padding: 0 24px; 56 + height: 56px; 12 57 display: flex; 13 - min-height: 100vh; 14 58 align-items: center; 15 - justify-content: center; 16 - font-family: var(--font-geist-sans); 17 - background-color: var(--background); 59 + justify-content: space-between; 18 60 } 19 61 20 - .main { 62 + .logo { 63 + font-size: 1.05rem; 64 + font-weight: 700; 65 + color: var(--text); 66 + letter-spacing: -0.01em; 67 + text-decoration: none; 21 68 display: flex; 22 - min-height: 100vh; 23 - width: 100%; 24 - max-width: 800px; 25 - flex-direction: column; 26 - align-items: flex-start; 27 - justify-content: space-between; 28 - background-color: var(--foreground); 29 - padding: 120px 60px; 69 + align-items: center; 70 + gap: 8px; 71 + } 72 + .logo:hover { text-decoration: none; } 73 + 74 + .logoIcon { 75 + width: 22px; 76 + height: 22px; 77 + flex-shrink: 0; 30 78 } 31 79 32 - .intro { 80 + .navLinks { 33 81 display: flex; 34 - flex-direction: column; 35 - align-items: flex-start; 36 - text-align: left; 37 82 gap: 24px; 83 + align-items: center; 38 84 } 39 85 40 - .intro h1 { 41 - max-width: 320px; 42 - font-size: 40px; 43 - font-weight: 600; 44 - line-height: 48px; 45 - letter-spacing: -2.4px; 46 - text-wrap: balance; 47 - color: var(--text-primary); 86 + .navLinks a { 87 + font-size: 0.875rem; 88 + color: var(--text-2); 89 + text-decoration: none; 90 + transition: color 0.12s; 48 91 } 49 92 50 - .intro p { 51 - max-width: 440px; 52 - font-size: 18px; 53 - line-height: 32px; 54 - text-wrap: balance; 55 - color: var(--text-secondary); 93 + .navLinks a:hover { 94 + color: var(--blue); 95 + text-decoration: none; 56 96 } 57 97 58 - .intro a { 59 - font-weight: 500; 60 - color: var(--text-primary); 98 + /* ── Hero ─────────────────────────────────────────── */ 99 + .hero { 100 + padding: 72px 24px 56px; 101 + text-align: center; 102 + background: linear-gradient(180deg, #e8f3ff 0%, #f8fafc 50%, var(--bg) 100%); 103 + border-bottom: 1px solid var(--border); 61 104 } 62 105 63 - .ctas { 106 + @media (prefers-color-scheme: dark) { 107 + .hero { 108 + background: linear-gradient(180deg, #0a1628 0%, #0d1520 50%, var(--bg) 100%); 109 + } 110 + } 111 + 112 + .title { 113 + font-size: clamp(2.2rem, 6vw, 3.5rem); 114 + font-weight: 800; 115 + letter-spacing: -0.04em; 116 + line-height: 1.08; 117 + margin-bottom: 14px; 118 + background: linear-gradient( 119 + 90deg, 120 + var(--text) 0%, 121 + var(--blue) 40%, 122 + var(--text) 55%, 123 + var(--text) 100% 124 + ); 125 + background-size: 300% auto; 126 + -webkit-background-clip: text; 127 + background-clip: text; 128 + color: transparent; 129 + animation: shimmer 6s linear infinite; 130 + } 131 + 132 + .subtitle { 133 + font-size: 1.1rem; 134 + color: var(--text-2); 135 + max-width: 480px; 136 + margin: 0 auto 40px; 137 + line-height: 1.6; 138 + } 139 + 140 + /* ── Input row ────────────────────────────────────── */ 141 + .inputWrapper { 142 + position: relative; 64 143 display: flex; 65 - flex-direction: row; 66 - width: 100%; 67 - max-width: 440px; 68 - gap: 16px; 69 - font-size: 14px; 144 + gap: 8px; 145 + max-width: 620px; 146 + margin: 0 auto 14px; 147 + } 148 + 149 + .input { 150 + flex: 1; 151 + height: 52px; 152 + padding: 0 18px; 153 + border: 2px solid var(--border); 154 + border-radius: var(--radius); 155 + font-size: 0.95rem; 156 + color: var(--text); 157 + background: var(--bg); 158 + outline: none; 159 + transition: border-color 0.15s, box-shadow 0.15s; 160 + min-width: 0; 70 161 } 71 162 72 - .ctas a { 163 + .input::placeholder { color: var(--text-3); } 164 + 165 + .input:focus { 166 + border-color: var(--blue); 167 + box-shadow: 0 0 0 3px rgba(0, 133, 255, 0.12); 168 + } 169 + 170 + .detectedBadge { 171 + position: absolute; 172 + right: 138px; 173 + top: 50%; 174 + transform: translateY(-50%); 175 + display: inline-flex; 176 + align-items: center; 177 + padding: 2px 8px; 178 + background: var(--blue-pale); 179 + color: var(--blue); 180 + border-radius: 4px; 181 + font-size: 0.68rem; 182 + font-weight: 700; 183 + letter-spacing: 0.05em; 184 + text-transform: uppercase; 185 + pointer-events: none; 186 + animation: fadeIn 0.15s ease; 187 + white-space: nowrap; 188 + } 189 + 190 + .convertBtn { 191 + height: 52px; 192 + padding: 0 24px; 193 + background: var(--blue); 194 + color: #fff; 195 + border: none; 196 + border-radius: var(--radius); 197 + font-size: 0.95rem; 198 + font-weight: 600; 199 + cursor: pointer; 200 + white-space: nowrap; 201 + transition: background 0.15s, transform 0.1s, box-shadow 0.15s; 202 + flex-shrink: 0; 203 + } 204 + 205 + .convertBtn:hover { 206 + background: var(--blue-dark); 207 + box-shadow: 0 4px 14px rgba(0, 133, 255, 0.35); 208 + } 209 + 210 + .convertBtn:active { 211 + animation: pop 0.15s ease; 212 + } 213 + 214 + /* ── Pills ────────────────────────────────────────── */ 215 + .pills { 73 216 display: flex; 217 + gap: 8px; 74 218 justify-content: center; 219 + flex-wrap: wrap; 220 + margin-top: 4px; 221 + } 222 + 223 + .pill { 224 + display: inline-flex; 75 225 align-items: center; 76 - height: 40px; 77 - padding: 0 16px; 78 - border-radius: 128px; 79 - border: 1px solid transparent; 80 - transition: 0.2s; 226 + gap: 5px; 227 + padding: 6px 14px; 228 + background: var(--bg); 229 + border: 1px solid var(--border); 230 + border-radius: 100px; 231 + font-size: 0.82rem; 232 + color: var(--text-2); 81 233 cursor: pointer; 82 - width: fit-content; 83 - font-weight: 500; 234 + transition: border-color 0.12s, color 0.12s, background 0.12s, transform 0.1s; 235 + text-decoration: none; 236 + user-select: none; 237 + } 238 + 239 + .pill:hover { 240 + border-color: var(--blue); 241 + color: var(--blue); 242 + background: var(--blue-pale); 243 + transform: translateY(-1px); 244 + text-decoration: none; 84 245 } 85 246 86 - a.primary { 87 - background: var(--text-primary); 88 - color: var(--background); 247 + .pill:active { 248 + transform: translateY(0) scale(0.97); 249 + } 250 + 251 + /* ── Result ───────────────────────────────────────── */ 252 + .resultSection { 253 + max-width: 900px; 254 + width: 100%; 255 + margin: 32px auto 0; 256 + padding: 0 24px; 257 + animation: slideDown 0.2s ease; 258 + } 259 + 260 + .resultCard { 261 + border: 1px solid var(--border); 262 + border-radius: var(--radius); 263 + overflow: hidden; 264 + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06); 265 + } 266 + 267 + @media (prefers-color-scheme: dark) { 268 + .resultCard { 269 + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3); 270 + } 271 + } 272 + 273 + /* Result top bar */ 274 + .resultBar { 275 + background: var(--bg-2); 276 + border-bottom: 1px solid var(--border); 277 + padding: 10px 14px; 278 + display: flex; 279 + align-items: center; 89 280 gap: 8px; 281 + min-height: 44px; 282 + flex-wrap: wrap; 90 283 } 91 284 92 - a.secondary { 93 - border-color: var(--button-secondary-border); 285 + .resultLabel { 286 + display: inline-flex; 287 + align-items: center; 288 + padding: 2px 8px; 289 + background: var(--blue-pale); 290 + color: var(--blue); 291 + border-radius: 4px; 292 + font-size: 0.68rem; 293 + font-weight: 700; 294 + letter-spacing: 0.06em; 295 + text-transform: uppercase; 296 + white-space: nowrap; 297 + flex-shrink: 0; 94 298 } 95 299 96 - /* Enable hover only on non-touch devices */ 97 - @media (hover: hover) and (pointer: fine) { 98 - a.primary:hover { 99 - background: var(--button-primary-hover); 100 - border-color: transparent; 300 + .resultUrl { 301 + flex: 1; 302 + font-family: var(--mono); 303 + font-size: 0.78rem; 304 + color: var(--text-2); 305 + overflow: hidden; 306 + text-overflow: ellipsis; 307 + white-space: nowrap; 308 + min-width: 0; 309 + } 310 + 311 + .resultActions { 312 + display: flex; 313 + gap: 6px; 314 + flex-shrink: 0; 315 + } 316 + 317 + .actionBtn { 318 + display: inline-flex; 319 + align-items: center; 320 + gap: 4px; 321 + padding: 5px 12px; 322 + font-size: 0.78rem; 323 + font-weight: 500; 324 + border-radius: 6px; 325 + border: 1px solid var(--border); 326 + background: var(--bg); 327 + cursor: pointer; 328 + color: var(--text-2); 329 + text-decoration: none; 330 + transition: background 0.1s, border-color 0.1s, color 0.1s, transform 0.1s; 331 + white-space: nowrap; 332 + user-select: none; 333 + } 334 + 335 + .actionBtn:hover { 336 + background: var(--bg-2); 337 + border-color: #cbd5e1; 338 + color: var(--text); 339 + text-decoration: none; 340 + } 341 + 342 + .actionBtn:active { 343 + transform: scale(0.96); 344 + } 345 + 346 + .actionBtnSuccess { 347 + background: #dcfce7 !important; 348 + border-color: #86efac !important; 349 + color: #16a34a !important; 350 + } 351 + 352 + @media (prefers-color-scheme: dark) { 353 + .actionBtnSuccess { 354 + background: #052e16 !important; 355 + border-color: #166534 !important; 356 + color: #4ade80 !important; 101 357 } 358 + } 102 359 103 - a.secondary:hover { 104 - background: var(--button-secondary-hover); 105 - border-color: transparent; 360 + /* Post/thread toggle */ 361 + .toggle { 362 + padding: 8px 14px; 363 + border-bottom: 1px solid var(--border); 364 + background: var(--bg); 365 + display: flex; 366 + gap: 4px; 367 + } 368 + 369 + .toggleBtn { 370 + padding: 5px 14px; 371 + font-size: 0.8rem; 372 + font-weight: 500; 373 + border-radius: 6px; 374 + border: 1px solid transparent; 375 + background: transparent; 376 + cursor: pointer; 377 + color: var(--text-2); 378 + transition: all 0.12s; 379 + } 380 + 381 + .toggleBtn:hover { 382 + background: var(--bg-2); 383 + color: var(--text); 384 + } 385 + 386 + .toggleActive { 387 + background: var(--blue-pale); 388 + border-color: #bfdbfe; 389 + color: var(--blue); 390 + } 391 + 392 + @media (prefers-color-scheme: dark) { 393 + .toggleActive { 394 + border-color: #1e3a5f; 106 395 } 107 396 } 108 397 398 + /* Preview toolbar */ 399 + .previewToolbar { 400 + display: flex; 401 + align-items: center; 402 + justify-content: space-between; 403 + padding: 8px 14px; 404 + border-bottom: 1px solid var(--border); 405 + background: var(--bg-2); 406 + } 407 + 408 + .charCount { 409 + font-family: var(--mono); 410 + font-size: 0.72rem; 411 + color: var(--text-3); 412 + } 413 + 414 + /* Preview content */ 415 + .preview { 416 + margin: 0; 417 + padding: 20px 22px; 418 + font-family: var(--mono); 419 + font-size: 0.8rem; 420 + line-height: 1.65; 421 + background: var(--bg); 422 + color: var(--text); 423 + overflow: auto; 424 + max-height: 520px; 425 + white-space: pre-wrap; 426 + word-break: break-word; 427 + tab-size: 2; 428 + } 429 + 430 + .previewError { 431 + color: #dc2626; 432 + } 433 + 434 + @media (prefers-color-scheme: dark) { 435 + .previewError { color: #f87171; } 436 + } 437 + 438 + .previewLoading { 439 + padding: 40px; 440 + text-align: center; 441 + color: var(--text-3); 442 + font-size: 0.875rem; 443 + font-family: inherit; 444 + background: var(--bg); 445 + display: flex; 446 + align-items: center; 447 + justify-content: center; 448 + gap: 8px; 449 + } 450 + 451 + .spinner { 452 + display: inline-block; 453 + width: 16px; 454 + height: 16px; 455 + border: 2px solid var(--border); 456 + border-top-color: var(--blue); 457 + border-radius: 50%; 458 + animation: spin 0.7s linear infinite; 459 + flex-shrink: 0; 460 + } 461 + 462 + /* ── Info strip ───────────────────────────────────── */ 463 + .infoStrip { 464 + max-width: 900px; 465 + width: 100%; 466 + margin: 32px auto 0; 467 + padding: 0 24px; 468 + display: flex; 469 + gap: 10px; 470 + flex-wrap: wrap; 471 + } 472 + 473 + .infoItem { 474 + display: flex; 475 + align-items: center; 476 + gap: 8px; 477 + padding: 10px 16px; 478 + background: var(--bg-2); 479 + border: 1px solid var(--border); 480 + border-radius: var(--radius); 481 + font-size: 0.82rem; 482 + color: var(--text-2); 483 + flex: 1; 484 + min-width: 170px; 485 + transition: border-color 0.15s, transform 0.15s; 486 + } 487 + 488 + .infoItem:hover { 489 + border-color: var(--blue); 490 + transform: translateY(-1px); 491 + } 492 + 493 + .infoIcon { 494 + font-size: 1rem; 495 + flex-shrink: 0; 496 + } 497 + 498 + /* ── Endpoints Grid ───────────────────────────────── */ 499 + .endpointsSection { 500 + max-width: 900px; 501 + width: 100%; 502 + margin: 48px auto 0; 503 + padding: 0 24px; 504 + } 505 + 506 + .sectionTitle { 507 + font-size: 0.72rem; 508 + font-weight: 700; 509 + letter-spacing: 0.08em; 510 + text-transform: uppercase; 511 + color: var(--text-3); 512 + margin-bottom: 14px; 513 + } 514 + 515 + .grid { 516 + display: grid; 517 + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); 518 + gap: 10px; 519 + } 520 + 521 + .card { 522 + padding: 16px; 523 + border: 1px solid var(--border); 524 + border-radius: var(--radius); 525 + text-decoration: none; 526 + color: inherit; 527 + transition: border-color 0.15s, box-shadow 0.15s, background 0.15s, transform 0.15s; 528 + display: block; 529 + background: var(--bg); 530 + } 531 + 532 + .card:hover { 533 + border-color: var(--blue); 534 + box-shadow: 0 0 0 3px rgba(0, 133, 255, 0.08); 535 + text-decoration: none; 536 + transform: translateY(-2px); 537 + } 538 + 539 + .card:active { 540 + transform: translateY(0); 541 + } 542 + 543 + .cardBadge { 544 + display: inline-block; 545 + font-size: 0.62rem; 546 + font-weight: 700; 547 + letter-spacing: 0.06em; 548 + color: var(--blue); 549 + background: var(--blue-pale); 550 + padding: 2px 6px; 551 + border-radius: 4px; 552 + margin-bottom: 8px; 553 + } 554 + 555 + .cardPath { 556 + display: block; 557 + font-family: var(--mono); 558 + font-size: 0.78rem; 559 + font-weight: 600; 560 + color: var(--text); 561 + margin-bottom: 5px; 562 + word-break: break-all; 563 + } 564 + 565 + .cardDesc { 566 + font-size: 0.78rem; 567 + color: var(--text-2); 568 + margin: 0; 569 + line-height: 1.45; 570 + } 571 + 572 + /* ── Footer ───────────────────────────────────────── */ 573 + .footer { 574 + margin-top: auto; 575 + padding: 32px 24px; 576 + border-top: 1px solid var(--border); 577 + text-align: center; 578 + } 579 + 580 + .footerLinks { 581 + display: flex; 582 + gap: 20px; 583 + justify-content: center; 584 + flex-wrap: wrap; 585 + margin-bottom: 10px; 586 + } 587 + 588 + .footerLinks a { 589 + color: var(--text-2); 590 + font-size: 0.85rem; 591 + text-decoration: none; 592 + transition: color 0.12s; 593 + } 594 + 595 + .footerLinks a:hover { 596 + color: var(--blue); 597 + } 598 + 599 + .footerNote { 600 + font-size: 0.78rem; 601 + color: var(--text-3); 602 + } 603 + 604 + /* ── Responsive ───────────────────────────────────── */ 109 605 @media (max-width: 600px) { 110 - .main { 111 - padding: 48px 24px; 606 + .hero { 607 + padding: 48px 16px 40px; 112 608 } 113 609 114 - .intro { 115 - gap: 16px; 610 + .inputWrapper { 611 + flex-direction: column; 116 612 } 117 613 118 - .intro h1 { 119 - font-size: 32px; 120 - line-height: 40px; 121 - letter-spacing: -1.92px; 614 + .convertBtn { 615 + width: 100%; 122 616 } 123 - } 124 617 125 - @media (prefers-color-scheme: dark) { 126 - .logo { 127 - filter: invert(); 618 + .detectedBadge { 619 + display: none; 128 620 } 129 621 130 - .page { 131 - --background: #000; 132 - --foreground: #000; 622 + .resultSection, 623 + .endpointsSection, 624 + .infoStrip { 625 + padding: 0 16px; 626 + } 627 + 628 + .nav { 629 + padding: 0 16px; 630 + } 133 631 134 - --text-primary: #ededed; 135 - --text-secondary: #999; 632 + .navLinks { 633 + gap: 16px; 634 + } 136 635 137 - --button-primary-hover: #ccc; 138 - --button-secondary-hover: #1a1a1a; 139 - --button-secondary-border: #1a1a1a; 636 + .resultActions { 637 + flex-wrap: wrap; 140 638 } 141 639 }
+344 -58
app/page.tsx
··· 1 - import Image from "next/image"; 2 - import styles from "./page.module.css"; 1 + 'use client' 2 + 3 + import { useState, useCallback, useRef } from 'react' 4 + import s from './page.module.css' 5 + 6 + // ── URL parser ──────────────────────────────────────────────────────────────── 7 + 8 + interface Parsed { 9 + path: string 10 + label: string 11 + isPost: boolean 12 + } 13 + 14 + function parseBskyInput(raw: string): Parsed | null { 15 + const input = raw.trim() 16 + if (!input) return null 17 + 18 + try { 19 + const urlStr = /^https?:\/\//i.test(input) ? input : `https://${input}` 20 + const url = new URL(urlStr) 21 + 22 + if (['bsky.app', 'www.bsky.app', 'staging.bsky.app'].includes(url.hostname)) { 23 + const p = url.pathname.split('/').filter(Boolean) 24 + 25 + if (p.length === 0) return { path: '/trending', label: 'Trending', isPost: false } 26 + 27 + if (p[0] === 'profile' && p[1]) { 28 + const h = p[1] 29 + if (p.length === 2) return { path: `/profile/${h}`, label: 'Profile', isPost: false } 30 + if (p[2] === 'post' && p[3]) return { path: `/profile/${h}/post/${p[3]}`, label: 'Post', isPost: true } 31 + if (p[2] === 'feed' && p[3]) return { path: `/profile/${h}/feed/${p[3]}`, label: 'Feed', isPost: false } 32 + if (p[2] === 'likes') return { path: `/profile/${h}/likes`, label: 'Likes', isPost: false } 33 + if (p[2] === 'followers') return { path: `/profile/${h}/followers`, label: 'Followers', isPost: false } 34 + if (p[2] === 'following') return { path: `/profile/${h}/following`, label: 'Following', isPost: false } 35 + return { path: `/profile/${h}`, label: 'Profile', isPost: false } 36 + } 37 + 38 + if (p[0] === 'hashtag' && p[1]) 39 + return { path: `/search?q=${encodeURIComponent('#' + p[1])}`, label: 'Hashtag', isPost: false } 40 + 41 + if (p[0] === 'search') { 42 + const q = url.searchParams.get('q') ?? '' 43 + return { path: `/search?q=${encodeURIComponent(q)}`, label: 'Search', isPost: false } 44 + } 45 + 46 + if (p[0] === 'trending') 47 + return { path: '/trending', label: 'Trending', isPost: false } 48 + } 49 + } catch { 50 + // not a URL 51 + } 52 + 53 + if (input.startsWith('did:')) 54 + return { path: `/profile/${input}`, label: 'Profile', isPost: false } 55 + 56 + if (input.startsWith('#')) 57 + return { path: `/search?q=${encodeURIComponent(input)}`, label: 'Hashtag', isPost: false } 58 + 59 + if (/^[\w.-]+$/.test(input) && input.includes('.')) 60 + return { path: `/profile/${input}`, label: 'Profile', isPost: false } 61 + 62 + return { path: `/search?q=${encodeURIComponent(input)}`, label: 'Search', isPost: false } 63 + } 64 + 65 + function fmtBytes(n: number): string { 66 + if (n < 1000) return `${n} chars` 67 + return `${(n / 1000).toFixed(1)}k chars` 68 + } 69 + 70 + // ── Catalogue ───────────────────────────────────────────────────────────────── 71 + 72 + const ENDPOINTS = [ 73 + { path: '/profile/:handle', desc: 'Bio, stats, avatar/banner', example: '/profile/bsky.app' }, 74 + { path: '/profile/:handle/posts', desc: 'Paginated original posts', example: '/profile/bsky.app/posts' }, 75 + { path: '/profile/:handle/post/:rkey', desc: 'Single post with embeds', example: '/profile/bsky.app/post/3lhreomsy5k2x' }, 76 + { path: '/…/post/:rkey/thread', desc: 'Full self-reply thread', example: '/profile/bsky.app/post/3lhreomsy5k2x/thread' }, 77 + { path: '/profile/:handle/feed/:rkey', desc: 'Public custom feed', example: '/profile/bsky.app/feed/whats-hot' }, 78 + { path: '/profile/:handle/likes', desc: 'Posts the user liked', example: '/profile/bsky.app/likes' }, 79 + { path: '/profile/:handle/followers', desc: 'Follower list', example: '/profile/bsky.app/followers' }, 80 + { path: '/profile/:handle/following', desc: 'Following list', example: '/profile/bsky.app/following' }, 81 + { path: '/search?q=:query', desc: 'Full-text post search', example: '/search?q=atproto' }, 82 + { path: '/trending', desc: 'Trending topics right now', example: '/trending' }, 83 + { path: '/llms.txt', desc: 'Machine-readable API guide', example: '/llms.txt' }, 84 + ] 85 + 86 + const QUICK_LINKS = [ 87 + { label: '🔥 Trending', path: '/trending' }, 88 + { label: '👤 bsky.app', path: '/profile/bsky.app' }, 89 + { label: '🌐 What\'s Hot', path: '/profile/bsky.app/feed/whats-hot' }, 90 + { label: '#atproto', path: '/search?q=%23atproto' }, 91 + { label: '📰 Tech', path: '/search?q=tech' }, 92 + ] 93 + 94 + // ── Component ───────────────────────────────────────────────────────────────── 3 95 4 96 export default function Home() { 97 + const [input, setInput] = useState('') 98 + const [parsed, setParsed] = useState<Parsed | null>(null) 99 + const [viewMode, setViewMode] = useState<'post' | 'thread'>('thread') 100 + const [markdown, setMarkdown] = useState<string | null>(null) 101 + const [loading, setLoading] = useState(false) 102 + const [error, setError] = useState<string | null>(null) 103 + const [copiedUrl, setCopiedUrl] = useState(false) 104 + const [copiedMd, setCopiedMd] = useState(false) 105 + const abortRef = useRef<AbortController | null>(null) 106 + const resultRef = useRef<HTMLDivElement | null>(null) 107 + 108 + // Live type detection as user types 109 + const detected = input.trim() ? parseBskyInput(input) : null 110 + 111 + const getPath = useCallback((p: Parsed, mode: 'post' | 'thread') => { 112 + if (p.isPost && mode === 'thread') return p.path + '/thread' 113 + return p.path 114 + }, []) 115 + 116 + const fetchMd = useCallback(async (apiPath: string) => { 117 + if (abortRef.current) abortRef.current.abort() 118 + const ctrl = new AbortController() 119 + abortRef.current = ctrl 120 + setLoading(true) 121 + setError(null) 122 + setMarkdown(null) 123 + try { 124 + const res = await fetch(apiPath, { signal: ctrl.signal }) 125 + const text = await res.text() 126 + if (!res.ok) setError(text) 127 + else setMarkdown(text) 128 + } catch (e: unknown) { 129 + if (e instanceof Error && e.name !== 'AbortError') setError(e.message) 130 + } finally { 131 + setLoading(false) 132 + } 133 + }, []) 134 + 135 + const run = useCallback( 136 + (p: Parsed, mode: 'post' | 'thread') => { 137 + setParsed(p) 138 + fetchMd(getPath(p, mode)) 139 + setTimeout(() => resultRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }), 80) 140 + }, 141 + [fetchMd, getPath], 142 + ) 143 + 144 + const handleConvert = useCallback(() => { 145 + const p = parseBskyInput(input) 146 + if (!p) return 147 + run(p, viewMode) 148 + }, [input, viewMode, run]) 149 + 150 + const handleQuick = useCallback( 151 + (path: string) => { 152 + setInput(path) 153 + run({ path, label: 'Quick', isPost: false }, 'post') 154 + }, 155 + [run], 156 + ) 157 + 158 + const handleViewToggle = useCallback( 159 + (mode: 'post' | 'thread') => { 160 + setViewMode(mode) 161 + if (parsed?.isPost) fetchMd(getPath(parsed, mode)) 162 + }, 163 + [parsed, fetchMd, getPath], 164 + ) 165 + 166 + const copyUrl = useCallback(() => { 167 + if (!parsed) return 168 + const full = (typeof window !== 'undefined' ? window.location.origin : '') + getPath(parsed, viewMode) 169 + navigator.clipboard.writeText(full).then(() => { 170 + setCopiedUrl(true) 171 + setTimeout(() => setCopiedUrl(false), 2000) 172 + }) 173 + }, [parsed, viewMode, getPath]) 174 + 175 + const copyMarkdown = useCallback(() => { 176 + if (!markdown) return 177 + navigator.clipboard.writeText(markdown).then(() => { 178 + setCopiedMd(true) 179 + setTimeout(() => setCopiedMd(false), 2000) 180 + }) 181 + }, [markdown]) 182 + 183 + const activePath = parsed ? getPath(parsed, parsed.isPost ? viewMode : 'post') : null 184 + const charCount = markdown ? markdown.length : 0 185 + 5 186 return ( 6 - <div className={styles.page}> 7 - <main className={styles.main}> 8 - <Image 9 - className={styles.logo} 10 - src="/next.svg" 11 - alt="Next.js logo" 12 - width={100} 13 - height={20} 14 - priority 15 - /> 16 - <div className={styles.intro}> 17 - <h1>To get started, edit the page.tsx file.</h1> 18 - <p> 19 - Looking for a starting point or more instructions? Head over to{" "} 20 - <a 21 - href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" 22 - target="_blank" 23 - rel="noopener noreferrer" 24 - > 25 - Templates 26 - </a>{" "} 27 - or the{" "} 28 - <a 29 - href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" 30 - target="_blank" 31 - rel="noopener noreferrer" 32 - > 33 - Learning 34 - </a>{" "} 35 - center. 36 - </p> 187 + <div className={s.page}> 188 + {/* ── Header ── */} 189 + <header className={s.header}> 190 + <div className={s.nav}> 191 + <a href="/" className={s.logo}> 192 + <img src="/icon.svg" alt="" className={s.logoIcon} /> 193 + bsky.md 194 + </a> 195 + <nav className={s.navLinks}> 196 + <a href="/trending">Trending</a> 197 + <a href="/llms.txt">llms.txt</a> 198 + <a href="https://tangled.org/did:plc:4hawmtgzjx3vclfyphbhfn7v/bsky-md" target="_blank" rel="noopener noreferrer"> 199 + Source 200 + </a> 201 + </nav> 202 + </div> 203 + </header> 204 + 205 + {/* ── Hero ── */} 206 + <section className={s.hero}> 207 + <h1 className={s.title}>Bluesky, as Markdown.</h1> 208 + <p className={s.subtitle}> 209 + Paste any bsky.app URL — profile, post, feed, search, or hashtag — and get back clean, 210 + portable Markdown instantly. 211 + </p> 212 + 213 + <div className={s.inputWrapper}> 214 + <input 215 + className={s.input} 216 + type="text" 217 + placeholder="bsky.app/profile/... · post URL · #hashtag · search term" 218 + value={input} 219 + onChange={(e) => setInput(e.target.value)} 220 + onKeyDown={(e) => e.key === 'Enter' && handleConvert()} 221 + autoFocus 222 + spellCheck={false} 223 + autoComplete="off" 224 + /> 225 + {detected && <span className={s.detectedBadge}>{detected.label}</span>} 226 + <button className={s.convertBtn} onClick={handleConvert}> 227 + Convert → 228 + </button> 229 + </div> 230 + 231 + <div className={s.pills}> 232 + {QUICK_LINKS.map((ql) => ( 233 + <button key={ql.path} className={s.pill} onClick={() => handleQuick(ql.path)}> 234 + {ql.label} 235 + </button> 236 + ))} 237 + </div> 238 + </section> 239 + 240 + {/* ── Result ── */} 241 + {(loading || markdown !== null || error !== null) && parsed && ( 242 + <section className={s.resultSection} ref={resultRef}> 243 + <div className={s.resultCard}> 244 + 245 + {/* Top bar: label · url · copy url · open */} 246 + <div className={s.resultBar}> 247 + <span className={s.resultLabel}>{parsed.label}</span> 248 + <code className={s.resultUrl}>{activePath}</code> 249 + <div className={s.resultActions}> 250 + <button 251 + className={`${s.actionBtn} ${copiedUrl ? s.actionBtnSuccess : ''}`} 252 + onClick={copyUrl} 253 + > 254 + {copiedUrl ? '✓ Copied' : 'Copy URL'} 255 + </button> 256 + <a 257 + className={s.actionBtn} 258 + href={activePath ?? '#'} 259 + target="_blank" 260 + rel="noopener noreferrer" 261 + > 262 + Raw ↗ 263 + </a> 264 + </div> 265 + </div> 266 + 267 + {/* Post / Thread toggle */} 268 + {parsed.isPost && ( 269 + <div className={s.toggle}> 270 + <button 271 + className={`${s.toggleBtn} ${viewMode === 'thread' ? s.toggleActive : ''}`} 272 + onClick={() => handleViewToggle('thread')} 273 + > 274 + Full Thread 275 + </button> 276 + <button 277 + className={`${s.toggleBtn} ${viewMode === 'post' ? s.toggleActive : ''}`} 278 + onClick={() => handleViewToggle('post')} 279 + > 280 + Single Post 281 + </button> 282 + </div> 283 + )} 284 + 285 + {/* Preview toolbar */} 286 + {!loading && markdown && ( 287 + <div className={s.previewToolbar}> 288 + <span className={s.charCount}>{fmtBytes(charCount)}</span> 289 + <button 290 + className={`${s.actionBtn} ${copiedMd ? s.actionBtnSuccess : ''}`} 291 + onClick={copyMarkdown} 292 + > 293 + {copiedMd ? '✓ Copied!' : '📋 Copy Markdown'} 294 + </button> 295 + </div> 296 + )} 297 + 298 + {/* Content */} 299 + {loading && ( 300 + <div className={s.previewLoading}> 301 + <span className={s.spinner} /> 302 + Fetching… 303 + </div> 304 + )} 305 + {!loading && error && ( 306 + <pre className={`${s.preview} ${s.previewError}`}>{error}</pre> 307 + )} 308 + {!loading && markdown && ( 309 + <pre className={s.preview}>{markdown}</pre> 310 + )} 311 + </div> 312 + </section> 313 + )} 314 + 315 + {/* ── Info strip ── */} 316 + <div className={s.infoStrip} style={{ marginTop: 32 }}> 317 + <div className={s.infoItem}><span className={s.infoIcon}>🔓</span> No auth or API key</div> 318 + <div className={s.infoItem}><span className={s.infoIcon}>🌍</span> Open CORS from any origin</div> 319 + <div className={s.infoItem}><span className={s.infoIcon}>⚡</span> Edge-cached responses</div> 320 + <div className={s.infoItem}><span className={s.infoIcon}>🤖</span> LLM-friendly plain text</div> 321 + </div> 322 + 323 + {/* ── Endpoints ── */} 324 + <section className={s.endpointsSection} style={{ marginTop: 48 }}> 325 + <p className={s.sectionTitle}>All Endpoints</p> 326 + <div className={s.grid}> 327 + {ENDPOINTS.map((ep) => ( 328 + <a key={ep.path} className={s.card} href={ep.example} target="_blank" rel="noopener noreferrer"> 329 + <span className={s.cardBadge}>GET</span> 330 + <code className={s.cardPath}>{ep.path}</code> 331 + <p className={s.cardDesc}>{ep.desc}</p> 332 + </a> 333 + ))} 37 334 </div> 38 - <div className={styles.ctas}> 39 - <a 40 - className={styles.primary} 41 - href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app" 42 - target="_blank" 43 - rel="noopener noreferrer" 44 - > 45 - <Image 46 - className={styles.logo} 47 - src="/vercel.svg" 48 - alt="Vercel logomark" 49 - width={16} 50 - height={16} 51 - /> 52 - Deploy Now 53 - </a> 54 - <a 55 - className={styles.secondary} 56 - href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app" 57 - target="_blank" 58 - rel="noopener noreferrer" 59 - > 60 - Documentation 335 + </section> 336 + 337 + {/* ── Footer ── */} 338 + <footer className={s.footer} style={{ marginTop: 64 }}> 339 + <div className={s.footerLinks}> 340 + <a href="/trending">Trending</a> 341 + <a href="/llms.txt">llms.txt</a> 342 + <a href="/docs">API Docs</a> 343 + <a href="/search?q=atproto">Search</a> 344 + <a href="https://tangled.org/did:plc:4hawmtgzjx3vclfyphbhfn7v/bsky-md" target="_blank" rel="noopener noreferrer"> 345 + Source on Tangled 61 346 </a> 62 347 </div> 63 - </main> 348 + <p className={s.footerNote}>Content-Type: text/markdown · bsky-md.vercel.app</p> 349 + </footer> 64 350 </div> 65 - ); 351 + ) 66 352 }
+24
app/profile/[handle]/feed/[rkey]/route.ts
··· 1 + import { type NextRequest } from 'next/server' 2 + import { getCustomFeed } from '@/lib/bsky' 3 + import { renderCustomFeed } from '@/lib/markdown' 4 + import { markdownResponse, optionsResponse, baseUrl, handleRoute } from '@/lib/respond' 5 + 6 + export async function GET( 7 + req: NextRequest, 8 + { params }: { params: Promise<{ handle: string; rkey: string }> }, 9 + ) { 10 + return handleRoute(async () => { 11 + const { handle, rkey } = await params 12 + const url = new URL(req.url) 13 + const cursor = url.searchParams.get('cursor') ?? undefined 14 + const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '20', 10), 100) 15 + 16 + const page = await getCustomFeed(handle, rkey, cursor, limit) 17 + const md = renderCustomFeed(handle, rkey, page, baseUrl(req)) 18 + return markdownResponse(md) 19 + }) 20 + } 21 + 22 + export async function OPTIONS() { 23 + return optionsResponse() 24 + }
+24
app/profile/[handle]/followers/route.ts
··· 1 + import { type NextRequest } from 'next/server' 2 + import { getFollowers } from '@/lib/bsky' 3 + import { renderFollowers } from '@/lib/markdown' 4 + import { markdownResponse, optionsResponse, baseUrl, handleRoute } from '@/lib/respond' 5 + 6 + export async function GET( 7 + req: NextRequest, 8 + { params }: { params: Promise<{ handle: string }> }, 9 + ) { 10 + return handleRoute(async () => { 11 + const { handle } = await params 12 + const url = new URL(req.url) 13 + const cursor = url.searchParams.get('cursor') ?? undefined 14 + const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '50', 10), 100) 15 + 16 + const page = await getFollowers(handle, cursor, limit) 17 + const md = renderFollowers(handle, page, baseUrl(req)) 18 + return markdownResponse(md) 19 + }) 20 + } 21 + 22 + export async function OPTIONS() { 23 + return optionsResponse() 24 + }
+24
app/profile/[handle]/following/route.ts
··· 1 + import { type NextRequest } from 'next/server' 2 + import { getFollowing } from '@/lib/bsky' 3 + import { renderFollowing } from '@/lib/markdown' 4 + import { markdownResponse, optionsResponse, baseUrl, handleRoute } from '@/lib/respond' 5 + 6 + export async function GET( 7 + req: NextRequest, 8 + { params }: { params: Promise<{ handle: string }> }, 9 + ) { 10 + return handleRoute(async () => { 11 + const { handle } = await params 12 + const url = new URL(req.url) 13 + const cursor = url.searchParams.get('cursor') ?? undefined 14 + const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '50', 10), 100) 15 + 16 + const page = await getFollowing(handle, cursor, limit) 17 + const md = renderFollowing(handle, page, baseUrl(req)) 18 + return markdownResponse(md) 19 + }) 20 + } 21 + 22 + export async function OPTIONS() { 23 + return optionsResponse() 24 + }
+24
app/profile/[handle]/likes/route.ts
··· 1 + import { type NextRequest } from 'next/server' 2 + import { getLikes } from '@/lib/bsky' 3 + import { renderLikes } from '@/lib/markdown' 4 + import { markdownResponse, optionsResponse, baseUrl, handleRoute } from '@/lib/respond' 5 + 6 + export async function GET( 7 + req: NextRequest, 8 + { params }: { params: Promise<{ handle: string }> }, 9 + ) { 10 + return handleRoute(async () => { 11 + const { handle } = await params 12 + const url = new URL(req.url) 13 + const cursor = url.searchParams.get('cursor') ?? undefined 14 + const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '20', 10), 100) 15 + 16 + const page = await getLikes(handle, cursor, limit) 17 + const md = renderLikes(handle, page, baseUrl(req)) 18 + return markdownResponse(md) 19 + }) 20 + } 21 + 22 + export async function OPTIONS() { 23 + return optionsResponse() 24 + }
+20
app/profile/[handle]/post/[rkey]/route.ts
··· 1 + import { type NextRequest } from 'next/server' 2 + import { getPost } from '@/lib/bsky' 3 + import { renderPost } from '@/lib/markdown' 4 + import { markdownResponse, optionsResponse, baseUrl, handleRoute } from '@/lib/respond' 5 + 6 + export async function GET( 7 + req: NextRequest, 8 + { params }: { params: Promise<{ handle: string; rkey: string }> }, 9 + ) { 10 + return handleRoute(async () => { 11 + const { handle, rkey } = await params 12 + const post = await getPost(handle, rkey) 13 + const md = renderPost(post, baseUrl(req)) 14 + return markdownResponse(md) 15 + }) 16 + } 17 + 18 + export async function OPTIONS() { 19 + return optionsResponse() 20 + }
+20
app/profile/[handle]/post/[rkey]/thread/route.ts
··· 1 + import { type NextRequest } from 'next/server' 2 + import { getThread } from '@/lib/bsky' 3 + import { renderThread } from '@/lib/markdown' 4 + import { markdownResponse, optionsResponse, baseUrl, handleRoute } from '@/lib/respond' 5 + 6 + export async function GET( 7 + req: NextRequest, 8 + { params }: { params: Promise<{ handle: string; rkey: string }> }, 9 + ) { 10 + return handleRoute(async () => { 11 + const { handle, rkey } = await params 12 + const thread = await getThread(handle, rkey) 13 + const md = renderThread(thread, baseUrl(req)) 14 + return markdownResponse(md) 15 + }) 16 + } 17 + 18 + export async function OPTIONS() { 19 + return optionsResponse() 20 + }
+24
app/profile/[handle]/posts/route.ts
··· 1 + import { type NextRequest } from 'next/server' 2 + import { getFeed } from '@/lib/bsky' 3 + import { renderFeed } from '@/lib/markdown' 4 + import { markdownResponse, optionsResponse, baseUrl, handleRoute } from '@/lib/respond' 5 + 6 + export async function GET( 7 + req: NextRequest, 8 + { params }: { params: Promise<{ handle: string }> }, 9 + ) { 10 + return handleRoute(async () => { 11 + const { handle } = await params 12 + const url = new URL(req.url) 13 + const cursor = url.searchParams.get('cursor') ?? undefined 14 + const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '20', 10), 100) 15 + 16 + const page = await getFeed(handle, cursor, limit) 17 + const md = renderFeed(handle, page, baseUrl(req), cursor) 18 + return markdownResponse(md) 19 + }) 20 + } 21 + 22 + export async function OPTIONS() { 23 + return optionsResponse() 24 + }
+20
app/profile/[handle]/route.ts
··· 1 + import { type NextRequest } from 'next/server' 2 + import { getProfile } from '@/lib/bsky' 3 + import { renderProfile } from '@/lib/markdown' 4 + import { markdownResponse, errorResponse, optionsResponse, baseUrl, handleRoute } from '@/lib/respond' 5 + 6 + export async function GET( 7 + req: NextRequest, 8 + { params }: { params: Promise<{ handle: string }> }, 9 + ) { 10 + return handleRoute(async () => { 11 + const { handle } = await params 12 + const profile = await getProfile(handle) 13 + const md = renderProfile(profile, baseUrl(req)) 14 + return markdownResponse(md) 15 + }) 16 + } 17 + 18 + export async function OPTIONS() { 19 + return optionsResponse() 20 + }
+26
app/search/route.ts
··· 1 + import { type NextRequest } from 'next/server' 2 + import { searchPosts } from '@/lib/bsky' 3 + import { renderSearch } from '@/lib/markdown' 4 + import { markdownResponse, errorResponse, optionsResponse, baseUrl, handleRoute } from '@/lib/respond' 5 + 6 + export async function GET(req: NextRequest) { 7 + return handleRoute(async () => { 8 + const url = new URL(req.url) 9 + const query = url.searchParams.get('q') 10 + 11 + if (!query?.trim()) { 12 + return errorResponse('Missing required query parameter: q', 400) 13 + } 14 + 15 + const cursor = url.searchParams.get('cursor') ?? undefined 16 + const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '20', 10), 100) 17 + 18 + const page = await searchPosts(query, cursor, limit) 19 + const md = renderSearch(query, page, baseUrl(req)) 20 + return markdownResponse(md) 21 + }) 22 + } 23 + 24 + export async function OPTIONS() { 25 + return optionsResponse() 26 + }
+16
app/trending/route.ts
··· 1 + import { type NextRequest } from 'next/server' 2 + import { getTrending } from '@/lib/bsky' 3 + import { renderTrending } from '@/lib/markdown' 4 + import { markdownResponse, optionsResponse, baseUrl, handleRoute } from '@/lib/respond' 5 + 6 + export async function GET(req: NextRequest) { 7 + return handleRoute(async () => { 8 + const data = await getTrending() 9 + const md = renderTrending(data, baseUrl(req)) 10 + return markdownResponse(md) 11 + }) 12 + } 13 + 14 + export async function OPTIONS() { 15 + return optionsResponse() 16 + }
+317
lib/bsky.ts
··· 1 + import { AtpAgent, AppBskyFeedDefs } from '@atproto/api' 2 + import type { 3 + AppBskyActorDefs, 4 + AppBskyRichtextFacet, 5 + AppBskyEmbedImages, 6 + AppBskyEmbedExternal, 7 + AppBskyEmbedRecord, 8 + AppBskyEmbedRecordWithMedia, 9 + AppBskyEmbedVideo, 10 + } from '@atproto/api' 11 + 12 + // ─── Client ────────────────────────────────────────────────────────────────── 13 + 14 + const agent = new AtpAgent({ service: 'https://public.api.bsky.app' }) 15 + 16 + // Search requires the full api.bsky.app (public.api.bsky.app blocks it) 17 + const searchAgent = new AtpAgent({ service: 'https://api.bsky.app' }) 18 + 19 + /** Simple in-memory cache for handle → DID resolution within a process lifetime. */ 20 + const didCache = new Map<string, string>() 21 + 22 + async function resolveDid(handle: string): Promise<string> { 23 + // Already a DID — no lookup needed 24 + if (handle.startsWith('did:')) return handle 25 + 26 + const cached = didCache.get(handle) 27 + if (cached) return cached 28 + 29 + const res = await agent.resolveHandle({ handle }) 30 + didCache.set(handle, res.data.did) 31 + return res.data.did 32 + } 33 + 34 + // ─── Types ──────────────────────────────────────────────────────────────────── 35 + 36 + export type Profile = AppBskyActorDefs.ProfileViewDetailed 37 + 38 + export interface PostData { 39 + uri: string 40 + cid: string 41 + author: AppBskyActorDefs.ProfileViewBasic 42 + text: string 43 + facets?: AppBskyRichtextFacet.Main[] 44 + embed?: EmbedView 45 + replyCount: number 46 + repostCount: number 47 + likeCount: number 48 + indexedAt: string 49 + /** rkey extracted from the AT URI */ 50 + rkey: string 51 + /** true if this post is a reply to another post */ 52 + isReply: boolean 53 + /** AT URI of the root post (if this is a reply) */ 54 + rootUri?: string 55 + } 56 + 57 + export type EmbedView = 58 + | ({ $type: 'app.bsky.embed.images#view' } & AppBskyEmbedImages.View) 59 + | ({ $type: 'app.bsky.embed.external#view' } & AppBskyEmbedExternal.View) 60 + | ({ $type: 'app.bsky.embed.record#view' } & AppBskyEmbedRecord.View) 61 + | ({ $type: 'app.bsky.embed.recordWithMedia#view' } & AppBskyEmbedRecordWithMedia.View) 62 + | ({ $type: 'app.bsky.embed.video#view' } & AppBskyEmbedVideo.View) 63 + 64 + export interface ActorPage { 65 + actors: AppBskyActorDefs.ProfileView[] 66 + cursor?: string 67 + } 68 + 69 + export interface SearchPage { 70 + posts: PostData[] 71 + cursor?: string 72 + hitsTotal?: number 73 + } 74 + 75 + export interface ThreadData { 76 + root: PostData 77 + replies: PostData[] 78 + } 79 + 80 + export interface FeedPage { 81 + posts: PostData[] 82 + cursor?: string 83 + } 84 + 85 + // ─── Helpers ────────────────────────────────────────────────────────────────── 86 + 87 + function rkeyFromUri(uri: string): string { 88 + return uri.split('/').at(-1) ?? uri 89 + } 90 + 91 + function postViewToPostData(post: AppBskyFeedDefs.PostView): PostData { 92 + const record = post.record as { 93 + text: string 94 + facets?: AppBskyRichtextFacet.Main[] 95 + reply?: { root: { uri: string }; parent: { uri: string } } 96 + } 97 + 98 + return { 99 + uri: post.uri, 100 + cid: post.cid, 101 + author: post.author, 102 + text: record.text ?? '', 103 + facets: record.facets, 104 + embed: post.embed as EmbedView | undefined, 105 + replyCount: post.replyCount ?? 0, 106 + repostCount: post.repostCount ?? 0, 107 + likeCount: post.likeCount ?? 0, 108 + indexedAt: post.indexedAt, 109 + rkey: rkeyFromUri(post.uri), 110 + isReply: !!record.reply, 111 + rootUri: record.reply?.root.uri, 112 + } 113 + } 114 + 115 + /** 116 + * Recursively walk a ThreadViewPost tree and collect all posts authored by 117 + * `authorDid`, in the order they appear depth-first (chronological for linear 118 + * threads). 119 + * 120 + * We cast through `unknown` when using the type guard because @atproto/api 121 + * v0.13+ wraps reply union members with `$Typed<T>` (adds `$type: string` 122 + * as required) while the base `ThreadViewPost` interface has `$type?` optional, 123 + * causing TS2677 when using the type predicate directly. 124 + */ 125 + function collectAuthorPosts( 126 + node: AppBskyFeedDefs.ThreadViewPost, 127 + authorDid: string, 128 + acc: PostData[] = [], 129 + ): PostData[] { 130 + if (node.post.author.did === authorDid) { 131 + acc.push(postViewToPostData(node.post)) 132 + } 133 + 134 + if (node.replies) { 135 + // Filter and cast via unknown to avoid TS2677 with $Typed<T> mismatch 136 + const authorReplies = (node.replies as unknown[]) 137 + .filter( 138 + (r) => 139 + AppBskyFeedDefs.isThreadViewPost(r) && 140 + (r as AppBskyFeedDefs.ThreadViewPost).post.author.did === authorDid, 141 + ) 142 + .map((r) => r as AppBskyFeedDefs.ThreadViewPost) 143 + 144 + authorReplies.sort( 145 + (a, b) => new Date(a.post.indexedAt).getTime() - new Date(b.post.indexedAt).getTime(), 146 + ) 147 + for (const reply of authorReplies) { 148 + collectAuthorPosts(reply, authorDid, acc) 149 + } 150 + } 151 + 152 + return acc 153 + } 154 + 155 + // ─── Public API ─────────────────────────────────────────────────────────────── 156 + 157 + export async function getProfile(handle: string): Promise<Profile> { 158 + const res = await agent.getProfile({ actor: handle }) 159 + return res.data 160 + } 161 + 162 + export async function getFeed( 163 + handle: string, 164 + cursor?: string, 165 + limit = 20, 166 + ): Promise<FeedPage> { 167 + const res = await agent.getAuthorFeed({ 168 + actor: handle, 169 + cursor, 170 + limit: Math.min(limit, 100), 171 + filter: 'posts_no_replies', 172 + }) 173 + 174 + return { 175 + posts: res.data.feed.map(({ post }) => postViewToPostData(post)), 176 + cursor: res.data.cursor, 177 + } 178 + } 179 + 180 + export async function getPost(handle: string, rkey: string): Promise<PostData> { 181 + const did = await resolveDid(handle) 182 + const uri = `at://${did}/app.bsky.feed.post/${rkey}` 183 + const res = await agent.getPosts({ uris: [uri] }) 184 + const post = res.data.posts[0] 185 + if (!post) throw Object.assign(new Error('Post not found'), { status: 404 }) 186 + return postViewToPostData(post) 187 + } 188 + 189 + export async function getLikes( 190 + handle: string, 191 + cursor?: string, 192 + limit = 20, 193 + ): Promise<FeedPage> { 194 + const res = await agent.getActorLikes({ 195 + actor: handle, 196 + cursor, 197 + limit: Math.min(limit, 100), 198 + }) 199 + 200 + return { 201 + posts: res.data.feed.map(({ post }) => postViewToPostData(post)), 202 + cursor: res.data.cursor, 203 + } 204 + } 205 + 206 + export async function getFollowers( 207 + handle: string, 208 + cursor?: string, 209 + limit = 50, 210 + ): Promise<ActorPage> { 211 + const res = await agent.getFollowers({ 212 + actor: handle, 213 + cursor, 214 + limit: Math.min(limit, 100), 215 + }) 216 + 217 + return { 218 + actors: res.data.followers, 219 + cursor: res.data.cursor, 220 + } 221 + } 222 + 223 + export async function getFollowing( 224 + handle: string, 225 + cursor?: string, 226 + limit = 50, 227 + ): Promise<ActorPage> { 228 + const res = await agent.getFollows({ 229 + actor: handle, 230 + cursor, 231 + limit: Math.min(limit, 100), 232 + }) 233 + 234 + return { 235 + actors: res.data.follows, 236 + cursor: res.data.cursor, 237 + } 238 + } 239 + 240 + export async function searchPosts( 241 + query: string, 242 + cursor?: string, 243 + limit = 20, 244 + ): Promise<SearchPage> { 245 + const res = await searchAgent.app.bsky.feed.searchPosts({ 246 + q: query, 247 + cursor, 248 + limit: Math.min(limit, 100), 249 + }) 250 + 251 + return { 252 + posts: res.data.posts.map((post) => postViewToPostData(post)), 253 + cursor: res.data.cursor, 254 + hitsTotal: res.data.hitsTotal, 255 + } 256 + } 257 + 258 + export interface TrendingTopic { 259 + topic: string 260 + displayName?: string 261 + link?: string 262 + startedAt?: string 263 + status?: string 264 + postCount?: number 265 + } 266 + 267 + export interface TrendingData { 268 + topics: TrendingTopic[] 269 + } 270 + 271 + export async function getCustomFeed( 272 + handle: string, 273 + rkey: string, 274 + cursor?: string, 275 + limit = 20, 276 + ): Promise<FeedPage> { 277 + const did = await resolveDid(handle) 278 + const feedUri = `at://${did}/app.bsky.feed.generator/${rkey}` 279 + const res = await agent.app.bsky.feed.getFeed({ 280 + feed: feedUri, 281 + cursor, 282 + limit: Math.min(limit, 100), 283 + }) 284 + return { 285 + posts: res.data.feed.map(({ post }) => postViewToPostData(post)), 286 + cursor: res.data.cursor, 287 + } 288 + } 289 + 290 + export async function getTrending(): Promise<TrendingData> { 291 + const res = await fetch( 292 + 'https://public.api.bsky.app/xrpc/app.bsky.unspecced.getTrendingTopics', 293 + { next: { revalidate: 60 } } as RequestInit, 294 + ) 295 + if (!res.ok) throw Object.assign(new Error('Failed to fetch trending topics'), { status: res.status }) 296 + const data = (await res.json()) as { topics?: TrendingTopic[] } 297 + return { topics: data.topics ?? [] } 298 + } 299 + 300 + export async function getThread(handle: string, rkey: string): Promise<ThreadData> { 301 + const did = await resolveDid(handle) 302 + const uri = `at://${did}/app.bsky.feed.post/${rkey}` 303 + const res = await agent.getPostThread({ uri, depth: 1000, parentHeight: 0 }) 304 + 305 + if (!AppBskyFeedDefs.isThreadViewPost(res.data.thread as unknown)) { 306 + throw Object.assign(new Error('Thread not found'), { status: 404 }) 307 + } 308 + 309 + const root = res.data.thread as AppBskyFeedDefs.ThreadViewPost 310 + const authorDid = root.post.author.did 311 + 312 + // Collect all posts: start with root, then replies recursively 313 + const all = collectAuthorPosts(root, authorDid) 314 + const [rootPost, ...replies] = all 315 + 316 + return { root: rootPost, replies } 317 + }
+625
lib/markdown.ts
··· 1 + import type { 2 + AppBskyRichtextFacet, 3 + AppBskyEmbedImages, 4 + AppBskyEmbedExternal, 5 + AppBskyEmbedRecord, 6 + AppBskyEmbedRecordWithMedia, 7 + AppBskyEmbedVideo, 8 + AppBskyActorDefs, 9 + } from '@atproto/api' 10 + import type { Profile, PostData, ThreadData, FeedPage, EmbedView, ActorPage, SearchPage, TrendingData } from './bsky' 11 + 12 + // ─── Helpers ────────────────────────────────────────────────────────────────── 13 + 14 + function formatDate(iso: string): string { 15 + return new Date(iso).toLocaleDateString('en-US', { 16 + year: 'numeric', 17 + month: 'long', 18 + day: 'numeric', 19 + }) 20 + } 21 + 22 + function formatNumber(n: number): string { 23 + return n.toLocaleString('en-US') 24 + } 25 + 26 + function bskyPostUrl(handle: string, rkey: string): string { 27 + return `https://bsky.app/profile/${handle}/post/${rkey}` 28 + } 29 + 30 + function bskyProfileUrl(handle: string): string { 31 + return `https://bsky.app/profile/${handle}` 32 + } 33 + 34 + function apiPostUrl(handle: string, rkey: string): string { 35 + return `/profile/${handle}/post/${rkey}` 36 + } 37 + 38 + function apiThreadUrl(handle: string, rkey: string): string { 39 + return `/profile/${handle}/post/${rkey}/thread` 40 + } 41 + 42 + function apiProfileUrl(handle: string): string { 43 + return `/profile/${handle}` 44 + } 45 + 46 + function apiPostsUrl(handle: string): string { 47 + return `/profile/${handle}/posts` 48 + } 49 + 50 + function hr(): string { 51 + return '\n\n---\n\n' 52 + } 53 + 54 + // ─── Rich Text / Facets ─────────────────────────────────────────────────────── 55 + 56 + /** 57 + * Convert Bluesky rich text (text + facets) to Markdown. 58 + * Facets use UTF-8 byte offsets, so we work at the byte level. 59 + */ 60 + export function richTextToMarkdown( 61 + text: string, 62 + facets?: AppBskyRichtextFacet.Main[], 63 + ): string { 64 + if (!facets || facets.length === 0) return escapeMarkdown(text) 65 + 66 + const encoder = new TextEncoder() 67 + const decoder = new TextDecoder() 68 + const bytes = encoder.encode(text) 69 + 70 + // Sort facets by byteStart; discard overlapping ones 71 + const sorted = [...facets] 72 + .filter( 73 + (f) => 74 + f.index.byteStart >= 0 && 75 + f.index.byteEnd <= bytes.length && 76 + f.index.byteStart < f.index.byteEnd, 77 + ) 78 + .sort((a, b) => a.index.byteStart - b.index.byteStart) 79 + 80 + let result = '' 81 + let cursor = 0 82 + 83 + for (const facet of sorted) { 84 + const { byteStart, byteEnd } = facet.index 85 + if (byteStart < cursor) continue // skip overlapping 86 + 87 + // Plain text before this facet 88 + result += escapeMarkdown(decoder.decode(bytes.slice(cursor, byteStart))) 89 + 90 + const facetText = decoder.decode(bytes.slice(byteStart, byteEnd)) 91 + const feature = facet.features[0] 92 + 93 + if (!feature) { 94 + result += escapeMarkdown(facetText) 95 + } else if (feature.$type === 'app.bsky.richtext.facet#link') { 96 + const f = feature as AppBskyRichtextFacet.Link 97 + result += `[${escapeMarkdown(facetText)}](${f.uri})` 98 + } else if (feature.$type === 'app.bsky.richtext.facet#mention') { 99 + const f = feature as AppBskyRichtextFacet.Mention 100 + result += `[${escapeMarkdown(facetText)}](${bskyProfileUrl(f.did)})` 101 + } else if (feature.$type === 'app.bsky.richtext.facet#tag') { 102 + const f = feature as AppBskyRichtextFacet.Tag 103 + result += `[${escapeMarkdown(facetText)}](https://bsky.app/hashtag/${f.tag})` 104 + } else { 105 + result += escapeMarkdown(facetText) 106 + } 107 + 108 + cursor = byteEnd 109 + } 110 + 111 + // Remaining plain text 112 + result += escapeMarkdown(decoder.decode(bytes.slice(cursor))) 113 + return result 114 + } 115 + 116 + /** 117 + * Escape characters that have special meaning in Markdown, but only outside 118 + * of URLs/links (caller is responsible for not passing already-formatted spans). 119 + * We're conservative: only escape characters that commonly cause rendering 120 + * issues in ambient text. 121 + */ 122 + function escapeMarkdown(text: string): string { 123 + // Don't escape inside words — only escape structural markdown chars 124 + // This prevents turning normal text into accidental headers / bold / etc. 125 + return text 126 + .replace(/\\/g, '\\\\') 127 + .replace(/^(#{1,6}) /gm, (_, h) => `\\${h} `) // headings at line start 128 + .replace(/\*\*/g, '\\*\\*') 129 + .replace(/(?<!\*)\*(?!\*)/g, '\\*') // single asterisks (italic) 130 + .replace(/^> /gm, '\\> ') // blockquotes at line start 131 + .replace(/^---$/gm, '\\---') // HR-like lines 132 + } 133 + 134 + // ─── Embed Rendering ────────────────────────────────────────────────────────── 135 + 136 + function renderEmbed(embed: EmbedView, authorDid: string): string { 137 + const type = embed.$type as string 138 + 139 + if (type === 'app.bsky.embed.images#view') { 140 + const e = embed as unknown as AppBskyEmbedImages.View 141 + return e.images 142 + .map((img) => { 143 + const alt = img.alt || 'image' 144 + const lines = [`![${alt}](${img.fullsize})`] 145 + if (img.alt) lines.push(`*Alt: ${img.alt}*`) 146 + return lines.join('\n') 147 + }) 148 + .join('\n\n') 149 + } 150 + 151 + if (type === 'app.bsky.embed.external#view') { 152 + const e = embed as unknown as AppBskyEmbedExternal.View 153 + const { uri, title, description, thumb } = e.external 154 + const lines: string[] = [`**[${title || uri}](${uri})**`] 155 + if (description) lines.push(`> ${description}`) 156 + if (thumb) lines.push(`![Preview](${thumb})`) 157 + return lines.join('\n') 158 + } 159 + 160 + if (type === 'app.bsky.embed.record#view') { 161 + const e = embed as unknown as AppBskyEmbedRecord.View 162 + const rec = e.record 163 + if (rec.$type === 'app.bsky.embed.record#viewRecord') { 164 + const vr = rec as AppBskyEmbedRecord.ViewRecord 165 + const handle = vr.author.handle 166 + const rkey = vr.uri.split('/').at(-1) ?? '' 167 + const text = (vr.value as { text?: string }).text ?? '' 168 + const facets = (vr.value as { facets?: AppBskyRichtextFacet.Main[] }).facets 169 + const formattedText = richTextToMarkdown(text, facets) 170 + return [ 171 + `> **[@${handle}](${bskyProfileUrl(handle)})** · [${formatDate(vr.indexedAt)}](${bskyPostUrl(handle, rkey)})`, 172 + `>`, 173 + formattedText 174 + .split('\n') 175 + .map((l) => `> ${l}`) 176 + .join('\n'), 177 + ].join('\n') 178 + } 179 + return '' 180 + } 181 + 182 + if (type === 'app.bsky.embed.video#view') { 183 + const e = embed as unknown as AppBskyEmbedVideo.View 184 + const lines: string[] = [] 185 + if (e.thumbnail) lines.push(`![${e.alt || 'video thumbnail'}](${e.thumbnail})`) 186 + lines.push(`**Video**${e.alt ? `: ${e.alt}` : ''}`) 187 + if (e.playlist) lines.push(`[Watch (HLS)](${e.playlist})`) 188 + return lines.join('\n') 189 + } 190 + 191 + if (type === 'app.bsky.embed.recordWithMedia#view') { 192 + const e = embed as unknown as AppBskyEmbedRecordWithMedia.View 193 + const mediaPart = renderEmbed(e.media as EmbedView, authorDid) 194 + const recordPart = renderEmbed( 195 + { ...e.record, $type: 'app.bsky.embed.record#view' } as EmbedView, 196 + authorDid, 197 + ) 198 + return [mediaPart, recordPart].filter(Boolean).join('\n\n') 199 + } 200 + 201 + return '' 202 + } 203 + 204 + // ─── Post Block ─────────────────────────────────────────────────────────────── 205 + 206 + /** 207 + * Render a single PostData into a markdown block (no surrounding HR). 208 + */ 209 + export function renderPostBlock(post: PostData): string { 210 + const handle = post.author.handle 211 + const date = formatDate(post.indexedAt) 212 + const bskyUrl = bskyPostUrl(handle, post.rkey) 213 + 214 + const lines: string[] = [] 215 + 216 + // Author + timestamp header 217 + lines.push(`**[@${handle}](${bskyProfileUrl(handle)})** · [${date}](${bskyUrl})`) 218 + lines.push('') 219 + 220 + // Body text with rich text facets rendered as markdown 221 + const body = richTextToMarkdown(post.text, post.facets) 222 + if (body.trim()) { 223 + lines.push(body) 224 + } 225 + 226 + // Embed 227 + if (post.embed) { 228 + const embedMd = renderEmbed(post.embed, post.author.did) 229 + if (embedMd.trim()) { 230 + lines.push('') 231 + lines.push(embedMd) 232 + } 233 + } 234 + 235 + // Engagement stats 236 + lines.push('') 237 + lines.push( 238 + `💬 ${formatNumber(post.replyCount)} · 🔁 ${formatNumber(post.repostCount)} · ❤️ ${formatNumber(post.likeCount)}`, 239 + ) 240 + 241 + return lines.join('\n') 242 + } 243 + 244 + // ─── Profile ────────────────────────────────────────────────────────────────── 245 + 246 + export function renderProfile(profile: Profile, baseUrl: string): string { 247 + const handle = profile.handle 248 + const displayName = profile.displayName || handle 249 + const lines: string[] = [] 250 + 251 + lines.push(`# ${displayName} ([@${handle}](${bskyProfileUrl(handle)}))`) 252 + lines.push('') 253 + 254 + if (profile.description) { 255 + lines.push(profile.description) 256 + lines.push('') 257 + } 258 + 259 + // Avatar / Banner 260 + if (profile.avatar) { 261 + lines.push(`**Avatar:** ![avatar](${profile.avatar})`) 262 + lines.push(`**Avatar URL:** ${profile.avatar}`) 263 + } 264 + if (profile.banner) { 265 + lines.push(`**Banner:** ![banner](${profile.banner})`) 266 + lines.push(`**Banner URL:** ${profile.banner}`) 267 + } 268 + lines.push('') 269 + 270 + // Stats 271 + lines.push( 272 + [ 273 + `**Followers:** ${formatNumber(profile.followersCount ?? 0)}`, 274 + `**Following:** ${formatNumber(profile.followsCount ?? 0)}`, 275 + `**Posts:** ${formatNumber(profile.postsCount ?? 0)}`, 276 + ].join(' · '), 277 + ) 278 + 279 + lines.push(hr()) 280 + 281 + // Links to API endpoints 282 + lines.push(`## Posts`) 283 + lines.push('') 284 + lines.push( 285 + `[View posts via this API](${baseUrl}${apiPostsUrl(handle)}) · [View on Bluesky](${bskyProfileUrl(handle)})`, 286 + ) 287 + 288 + lines.push(hr()) 289 + 290 + lines.push( 291 + `*Profile retrieved via [bsky-markdown-api](${baseUrl}) · [DID: ${profile.did}](${bskyProfileUrl(handle)})*`, 292 + ) 293 + 294 + return lines.join('\n') 295 + } 296 + 297 + // ─── Feed / Posts List ──────────────────────────────────────────────────────── 298 + 299 + export function renderFeed( 300 + handle: string, 301 + page: FeedPage, 302 + baseUrl: string, 303 + cursor?: string, 304 + ): string { 305 + const lines: string[] = [] 306 + 307 + lines.push(`# Posts by [@${handle}](${bskyProfileUrl(handle)})`) 308 + lines.push('') 309 + lines.push( 310 + `[Profile](${baseUrl}${apiProfileUrl(handle)}) · [View on Bluesky](${bskyProfileUrl(handle)})`, 311 + ) 312 + 313 + if (page.posts.length === 0) { 314 + lines.push(hr()) 315 + lines.push('*No posts found.*') 316 + return lines.join('\n') 317 + } 318 + 319 + for (const post of page.posts) { 320 + lines.push(hr()) 321 + lines.push(renderPostBlock(post)) 322 + lines.push('') 323 + lines.push( 324 + `[View post](${baseUrl}${apiPostUrl(handle, post.rkey)}) · [View thread](${baseUrl}${apiThreadUrl(handle, post.rkey)}) · [View on Bluesky](${bskyPostUrl(handle, post.rkey)})`, 325 + ) 326 + } 327 + 328 + lines.push(hr()) 329 + 330 + // Pagination 331 + if (page.cursor) { 332 + lines.push( 333 + `[Next page →](${baseUrl}${apiPostsUrl(handle)}?cursor=${encodeURIComponent(page.cursor)})`, 334 + ) 335 + } else { 336 + lines.push('*End of posts.*') 337 + } 338 + 339 + return lines.join('\n') 340 + } 341 + 342 + // ─── Single Post ────────────────────────────────────────────────────────────── 343 + 344 + export function renderPost(post: PostData, baseUrl: string): string { 345 + const handle = post.author.handle 346 + const lines: string[] = [] 347 + 348 + lines.push(`# Post by [@${handle}](${bskyProfileUrl(handle)})`) 349 + lines.push('') 350 + 351 + if (post.isReply) { 352 + lines.push(`*This post is a reply.*`) 353 + lines.push('') 354 + } 355 + 356 + lines.push(renderPostBlock(post)) 357 + 358 + lines.push(hr()) 359 + 360 + lines.push( 361 + `[View thread](${baseUrl}${apiThreadUrl(handle, post.rkey)}) · [View profile](${baseUrl}${apiProfileUrl(handle)}) · [View on Bluesky](${bskyPostUrl(handle, post.rkey)})`, 362 + ) 363 + 364 + return lines.join('\n') 365 + } 366 + 367 + // ─── Likes ──────────────────────────────────────────────────────────────────── 368 + 369 + export function renderLikes(handle: string, page: FeedPage, baseUrl: string): string { 370 + const lines: string[] = [] 371 + 372 + lines.push(`# Posts liked by [@${handle}](${bskyProfileUrl(handle)})`) 373 + lines.push('') 374 + lines.push( 375 + `[Profile](${baseUrl}${apiProfileUrl(handle)}) · [View on Bluesky](${bskyProfileUrl(handle)})`, 376 + ) 377 + 378 + if (page.posts.length === 0) { 379 + lines.push(hr()) 380 + lines.push('*No liked posts found.*') 381 + return lines.join('\n') 382 + } 383 + 384 + for (const post of page.posts) { 385 + lines.push(hr()) 386 + lines.push(renderPostBlock(post)) 387 + lines.push('') 388 + lines.push( 389 + `[View post](${baseUrl}${apiPostUrl(post.author.handle, post.rkey)}) · [View on Bluesky](${bskyPostUrl(post.author.handle, post.rkey)})`, 390 + ) 391 + } 392 + 393 + lines.push(hr()) 394 + if (page.cursor) { 395 + lines.push( 396 + `[Next page →](${baseUrl}/profile/${handle}/likes?cursor=${encodeURIComponent(page.cursor)})`, 397 + ) 398 + } else { 399 + lines.push('*End of liked posts.*') 400 + } 401 + 402 + return lines.join('\n') 403 + } 404 + 405 + // ─── Followers / Following ──────────────────────────────────────────────────── 406 + 407 + function renderActorList( 408 + title: string, 409 + handle: string, 410 + page: ActorPage, 411 + baseUrl: string, 412 + nextUrl: (cursor: string) => string, 413 + ): string { 414 + const lines: string[] = [] 415 + 416 + lines.push(`# ${title}`) 417 + lines.push('') 418 + lines.push( 419 + `[Profile](${baseUrl}${apiProfileUrl(handle)}) · [View on Bluesky](${bskyProfileUrl(handle)})`, 420 + ) 421 + 422 + if (page.actors.length === 0) { 423 + lines.push(hr()) 424 + lines.push('*None found.*') 425 + return lines.join('\n') 426 + } 427 + 428 + lines.push(hr()) 429 + 430 + for (const actor of page.actors) { 431 + const displayName = actor.displayName || actor.handle 432 + lines.push( 433 + `**[${displayName}](${baseUrl}${apiProfileUrl(actor.handle)})** · [@${actor.handle}](${bskyProfileUrl(actor.handle)})`, 434 + ) 435 + if (actor.description) { 436 + lines.push(`> ${actor.description.split('\n')[0]}`) 437 + } 438 + lines.push('') 439 + } 440 + 441 + lines.push(hr()) 442 + 443 + if (page.cursor) { 444 + lines.push(`[Next page →](${nextUrl(page.cursor)})`) 445 + } else { 446 + lines.push('*End of list.*') 447 + } 448 + 449 + return lines.join('\n') 450 + } 451 + 452 + export function renderFollowers(handle: string, page: ActorPage, baseUrl: string): string { 453 + return renderActorList( 454 + `Followers of [@${handle}](${bskyProfileUrl(handle)})`, 455 + handle, 456 + page, 457 + baseUrl, 458 + (cursor) => 459 + `${baseUrl}/profile/${handle}/followers?cursor=${encodeURIComponent(cursor)}`, 460 + ) 461 + } 462 + 463 + export function renderFollowing(handle: string, page: ActorPage, baseUrl: string): string { 464 + return renderActorList( 465 + `[@${handle}](${bskyProfileUrl(handle)}) is following`, 466 + handle, 467 + page, 468 + baseUrl, 469 + (cursor) => 470 + `${baseUrl}/profile/${handle}/following?cursor=${encodeURIComponent(cursor)}`, 471 + ) 472 + } 473 + 474 + // ─── Search ─────────────────────────────────────────────────────────────────── 475 + 476 + export function renderSearch(query: string, page: SearchPage, baseUrl: string): string { 477 + const lines: string[] = [] 478 + 479 + lines.push(`# Search results for "${escapeMarkdown(query)}"`) 480 + lines.push('') 481 + if (page.hitsTotal !== undefined) { 482 + lines.push(`*${formatNumber(page.hitsTotal)} total results*`) 483 + lines.push('') 484 + } 485 + 486 + if (page.posts.length === 0) { 487 + lines.push('*No posts found.*') 488 + return lines.join('\n') 489 + } 490 + 491 + for (const post of page.posts) { 492 + lines.push(hr()) 493 + lines.push(renderPostBlock(post)) 494 + lines.push('') 495 + lines.push( 496 + `[View post](${baseUrl}${apiPostUrl(post.author.handle, post.rkey)}) · [View thread](${baseUrl}${apiThreadUrl(post.author.handle, post.rkey)}) · [View on Bluesky](${bskyPostUrl(post.author.handle, post.rkey)})`, 497 + ) 498 + } 499 + 500 + lines.push(hr()) 501 + 502 + if (page.cursor) { 503 + lines.push( 504 + `[Next page →](${baseUrl}/search?q=${encodeURIComponent(query)}&cursor=${encodeURIComponent(page.cursor)})`, 505 + ) 506 + } else { 507 + lines.push('*End of results.*') 508 + } 509 + 510 + return lines.join('\n') 511 + } 512 + 513 + // ─── Custom Feed ────────────────────────────────────────────────────────────── 514 + 515 + export function renderCustomFeed( 516 + handle: string, 517 + rkey: string, 518 + page: FeedPage, 519 + baseUrl: string, 520 + ): string { 521 + const lines: string[] = [] 522 + 523 + lines.push(`# Feed: ${rkey} by [@${handle}](${bskyProfileUrl(handle)})`) 524 + lines.push('') 525 + lines.push( 526 + `[View on Bluesky](https://bsky.app/profile/${handle}/feed/${rkey}) · [Profile](${baseUrl}${apiProfileUrl(handle)})`, 527 + ) 528 + 529 + if (page.posts.length === 0) { 530 + lines.push(hr()) 531 + lines.push('*No posts in this feed.*') 532 + return lines.join('\n') 533 + } 534 + 535 + for (const post of page.posts) { 536 + lines.push(hr()) 537 + lines.push(renderPostBlock(post)) 538 + lines.push('') 539 + lines.push( 540 + `[View post](${baseUrl}${apiPostUrl(post.author.handle, post.rkey)}) · [View thread](${baseUrl}${apiThreadUrl(post.author.handle, post.rkey)}) · [View on Bluesky](${bskyPostUrl(post.author.handle, post.rkey)})`, 541 + ) 542 + } 543 + 544 + lines.push(hr()) 545 + 546 + if (page.cursor) { 547 + lines.push( 548 + `[Next page →](${baseUrl}/profile/${handle}/feed/${rkey}?cursor=${encodeURIComponent(page.cursor)})`, 549 + ) 550 + } else { 551 + lines.push('*End of feed.*') 552 + } 553 + 554 + return lines.join('\n') 555 + } 556 + 557 + // ─── Trending ───────────────────────────────────────────────────────────────── 558 + 559 + export function renderTrending(data: TrendingData, baseUrl: string): string { 560 + const lines: string[] = [] 561 + 562 + lines.push('# Trending on Bluesky') 563 + lines.push('') 564 + lines.push(`*${new Date().toUTCString()}*`) 565 + lines.push('') 566 + 567 + if (data.topics.length === 0) { 568 + lines.push('*No trending topics available.*') 569 + return lines.join('\n') 570 + } 571 + 572 + for (let i = 0; i < data.topics.length; i++) { 573 + const t = data.topics[i] 574 + const name = t.displayName || t.topic 575 + lines.push(`## ${i + 1}. ${name}`) 576 + lines.push('') 577 + lines.push( 578 + `[Search posts](${baseUrl}/search?q=${encodeURIComponent(t.topic)}) · [View on Bluesky](https://bsky.app/search?q=${encodeURIComponent(t.topic)})`, 579 + ) 580 + if (t.startedAt) { 581 + lines.push('') 582 + lines.push(`*Trending since ${formatDate(t.startedAt)}*`) 583 + } 584 + if (t.postCount) { 585 + lines.push(`*${formatNumber(t.postCount)} posts*`) 586 + } 587 + lines.push('') 588 + } 589 + 590 + return lines.join('\n') 591 + } 592 + 593 + // ─── Thread ─────────────────────────────────────────────────────────────────── 594 + 595 + export function renderThread(thread: ThreadData, baseUrl: string): string { 596 + const handle = thread.root.author.handle 597 + const lines: string[] = [] 598 + 599 + lines.push(`# Thread by [@${handle}](${bskyProfileUrl(handle)})`) 600 + lines.push('') 601 + lines.push( 602 + `[Profile](${baseUrl}${apiProfileUrl(handle)}) · [View on Bluesky](${bskyPostUrl(handle, thread.root.rkey)})`, 603 + ) 604 + 605 + // Root post 606 + lines.push(hr()) 607 + lines.push(renderPostBlock(thread.root)) 608 + 609 + // Replies from same author 610 + if (thread.replies.length > 0) { 611 + for (const reply of thread.replies) { 612 + lines.push(hr()) 613 + lines.push(renderPostBlock(reply)) 614 + } 615 + } 616 + 617 + lines.push(hr()) 618 + 619 + const postCount = 1 + thread.replies.length 620 + lines.push( 621 + `*${postCount} post${postCount !== 1 ? 's' : ''} by @${handle} · retrieved via [bsky-markdown-api](${baseUrl})*`, 622 + ) 623 + 624 + return lines.join('\n') 625 + }
+76
lib/respond.ts
··· 1 + const CORS_HEADERS = { 2 + 'Access-Control-Allow-Origin': '*', 3 + 'Access-Control-Allow-Methods': 'GET, OPTIONS', 4 + 'Access-Control-Allow-Headers': '*', 5 + 'Access-Control-Max-Age': '86400', 6 + } 7 + 8 + const CACHE_HEADERS = { 9 + 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300', 10 + } 11 + 12 + export function markdownResponse(body: string, status = 200): Response { 13 + return new Response(body, { 14 + status, 15 + headers: { 16 + 'Content-Type': 'text/markdown; charset=utf-8', 17 + Vary: 'Accept', 18 + ...CORS_HEADERS, 19 + ...CACHE_HEADERS, 20 + }, 21 + }) 22 + } 23 + 24 + export function errorResponse(message: string, status = 500): Response { 25 + return new Response(`Error: ${message}\n`, { 26 + status, 27 + headers: { 28 + 'Content-Type': 'text/plain; charset=utf-8', 29 + ...CORS_HEADERS, 30 + }, 31 + }) 32 + } 33 + 34 + export function optionsResponse(): Response { 35 + return new Response(null, { 36 + status: 204, 37 + headers: CORS_HEADERS, 38 + }) 39 + } 40 + 41 + /** 42 + * Extract the base URL of the current request (scheme + host, no trailing 43 + * slash) so we can generate fully-qualified links in markdown output. 44 + */ 45 + export function baseUrl(req: Request): string { 46 + const url = new URL(req.url) 47 + return `${url.protocol}//${url.host}` 48 + } 49 + 50 + /** 51 + * Wrap a route handler with consistent error handling. 52 + * Maps Bluesky API 400 "not found" errors to HTTP 404. 53 + */ 54 + export async function handleRoute( 55 + fn: () => Promise<Response>, 56 + ): Promise<Response> { 57 + try { 58 + return await fn() 59 + } catch (err: unknown) { 60 + const e = err as { message?: string; status?: number; error?: string } 61 + const message = e?.message ?? 'An unexpected error occurred' 62 + 63 + // Bluesky returns 400 InvalidRequest for most "not found" cases. 64 + // Map those to 404 so clients can distinguish missing resources from bad requests. 65 + let status = e?.status ?? 500 66 + if ( 67 + status === 400 && 68 + (e?.error === 'InvalidRequest' || e?.error === 'NotFound') && 69 + /not found/i.test(message) 70 + ) { 71 + status = 404 72 + } 73 + 74 + return errorResponse(message, status) 75 + } 76 + }
+134
package-lock.json
··· 8 8 "name": "bsky-markdown-api", 9 9 "version": "0.1.0", 10 10 "dependencies": { 11 + "@atproto/api": "^0.19.3", 11 12 "next": "16.1.6", 12 13 "react": "19.2.3", 13 14 "react-dom": "19.2.3" ··· 17 18 "@types/react": "^19", 18 19 "@types/react-dom": "^19", 19 20 "typescript": "^5" 21 + } 22 + }, 23 + "node_modules/@atproto/api": { 24 + "version": "0.19.3", 25 + "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.19.3.tgz", 26 + "integrity": "sha512-G8YpBpRouHdTAIagi/QQIUZOhGd1jfBQWkJy9QfxAzjjEpPvaVOSk4e1S85QzGLm/xbzVONzGkmdtiOSfP6wVg==", 27 + "license": "MIT", 28 + "dependencies": { 29 + "@atproto/common-web": "^0.4.18", 30 + "@atproto/lexicon": "^0.6.2", 31 + "@atproto/syntax": "^0.5.0", 32 + "@atproto/xrpc": "^0.7.7", 33 + "await-lock": "^2.2.2", 34 + "multiformats": "^9.9.0", 35 + "tlds": "^1.234.0", 36 + "zod": "^3.23.8" 37 + } 38 + }, 39 + "node_modules/@atproto/common-web": { 40 + "version": "0.4.18", 41 + "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.18.tgz", 42 + "integrity": "sha512-ilImzP+9N/mtse440kN60pGrEzG7wi4xsV13nGeLrS+Zocybc/ISOpKlbZM13o+twPJ+Q7veGLw9CtGg0GAFoQ==", 43 + "license": "MIT", 44 + "dependencies": { 45 + "@atproto/lex-data": "^0.0.13", 46 + "@atproto/lex-json": "^0.0.13", 47 + "@atproto/syntax": "^0.5.0", 48 + "zod": "^3.23.8" 49 + } 50 + }, 51 + "node_modules/@atproto/lex-data": { 52 + "version": "0.0.13", 53 + "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.13.tgz", 54 + "integrity": "sha512-7Z7RwZ1Y/JzBF/Tcn/I4UJ/vIGfh5zn1zjv0KX+flke2JtgFkSE8uh2hOtqgBQMNqE3zdJFM+dcSWln86hR3MQ==", 55 + "license": "MIT", 56 + "dependencies": { 57 + "multiformats": "^9.9.0", 58 + "tslib": "^2.8.1", 59 + "uint8arrays": "3.0.0", 60 + "unicode-segmenter": "^0.14.0" 61 + } 62 + }, 63 + "node_modules/@atproto/lex-json": { 64 + "version": "0.0.13", 65 + "resolved": "https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.0.13.tgz", 66 + "integrity": "sha512-hwLhkKaIHulGJpt0EfXAEWdrxqM2L1tV/tvilzhMp3QxPqYgXchFnrfVmLsyFDx6P6qkH1GsX/XC2V36U0UlPQ==", 67 + "license": "MIT", 68 + "dependencies": { 69 + "@atproto/lex-data": "^0.0.13", 70 + "tslib": "^2.8.1" 71 + } 72 + }, 73 + "node_modules/@atproto/lexicon": { 74 + "version": "0.6.2", 75 + "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.6.2.tgz", 76 + "integrity": "sha512-p3Ly6hinVZW0ETuAXZMeUGwuMm3g8HvQMQ41yyEE6AL0hAkfeKFaZKos6BdBrr6CjkpbrDZqE8M+5+QOceysMw==", 77 + "license": "MIT", 78 + "dependencies": { 79 + "@atproto/common-web": "^0.4.18", 80 + "@atproto/syntax": "^0.5.0", 81 + "iso-datestring-validator": "^2.2.2", 82 + "multiformats": "^9.9.0", 83 + "zod": "^3.23.8" 84 + } 85 + }, 86 + "node_modules/@atproto/syntax": { 87 + "version": "0.5.0", 88 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.5.0.tgz", 89 + "integrity": "sha512-UA2DSpGdOQzUQ4gi5SH+NEJz/YR3a3Fg3y2oh+xETDSiTRmA4VhHRCojhXAVsBxUT6EnItw190C/KN+DWW90kw==", 90 + "license": "MIT", 91 + "dependencies": { 92 + "tslib": "^2.8.1" 93 + } 94 + }, 95 + "node_modules/@atproto/xrpc": { 96 + "version": "0.7.7", 97 + "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.7.7.tgz", 98 + "integrity": "sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA==", 99 + "license": "MIT", 100 + "dependencies": { 101 + "@atproto/lexicon": "^0.6.0", 102 + "zod": "^3.23.8" 20 103 } 21 104 }, 22 105 "node_modules/@emnapi/runtime": { ··· 668 751 "@types/react": "^19.2.0" 669 752 } 670 753 }, 754 + "node_modules/await-lock": { 755 + "version": "2.2.2", 756 + "resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz", 757 + "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==", 758 + "license": "MIT" 759 + }, 671 760 "node_modules/baseline-browser-mapping": { 672 761 "version": "2.10.8", 673 762 "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz", ··· 722 811 "engines": { 723 812 "node": ">=8" 724 813 } 814 + }, 815 + "node_modules/iso-datestring-validator": { 816 + "version": "2.2.2", 817 + "resolved": "https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz", 818 + "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==", 819 + "license": "MIT" 820 + }, 821 + "node_modules/multiformats": { 822 + "version": "9.9.0", 823 + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", 824 + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", 825 + "license": "(Apache-2.0 AND MIT)" 725 826 }, 726 827 "node_modules/nanoid": { 727 828 "version": "3.3.11", ··· 945 1046 } 946 1047 } 947 1048 }, 1049 + "node_modules/tlds": { 1050 + "version": "1.261.0", 1051 + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", 1052 + "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==", 1053 + "license": "MIT", 1054 + "bin": { 1055 + "tlds": "bin.js" 1056 + } 1057 + }, 948 1058 "node_modules/tslib": { 949 1059 "version": "2.8.1", 950 1060 "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", ··· 965 1075 "node": ">=14.17" 966 1076 } 967 1077 }, 1078 + "node_modules/uint8arrays": { 1079 + "version": "3.0.0", 1080 + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz", 1081 + "integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==", 1082 + "license": "MIT", 1083 + "dependencies": { 1084 + "multiformats": "^9.4.2" 1085 + } 1086 + }, 968 1087 "node_modules/undici-types": { 969 1088 "version": "6.21.0", 970 1089 "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", 971 1090 "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", 972 1091 "dev": true, 973 1092 "license": "MIT" 1093 + }, 1094 + "node_modules/unicode-segmenter": { 1095 + "version": "0.14.5", 1096 + "resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.14.5.tgz", 1097 + "integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==", 1098 + "license": "MIT" 1099 + }, 1100 + "node_modules/zod": { 1101 + "version": "3.25.76", 1102 + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 1103 + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 1104 + "license": "MIT", 1105 + "funding": { 1106 + "url": "https://github.com/sponsors/colinhacks" 1107 + } 974 1108 } 975 1109 } 976 1110 }
+1
package.json
··· 8 8 "start": "next start" 9 9 }, 10 10 "dependencies": { 11 + "@atproto/api": "^0.19.3", 11 12 "next": "16.1.6", 12 13 "react": "19.2.3", 13 14 "react-dom": "19.2.3"
-1
public/file.svg
··· 1 - <svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
-1
public/globe.svg
··· 1 - <svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
+3
public/icon.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 27 27"> 2 + <path fill="#0085ff" d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z m 0.686342,-3.497495 c -0.643126,-0.394168 -0.33365,-1.249599 -0.359402,-1.870938 0.064,-0.749774 0.115321,-1.538054 0.452402,-2.221125 0.356724,-0.487008 1.226721,-0.299139 1.265134,0.325689 -0.02558,0.628509 -0.314101,1.25416 -0.279646,1.9057 -0.07482,0.544043 0.05418,1.155133 -0.186476,1.652391 -0.197455,0.275121 -0.599638,0.355105 -0.892012,0.208283 z m -2.808766,-0.358124 c -0.605767,-0.328664 -0.4133176,-1.155655 -0.5083256,-1.73063 0.078762,-0.66567 0.013203,-1.510085 0.5705316,-1.976886 0.545037,-0.380109 1.286917,0.270803 1.029164,0.868384 -0.274913,0.755214 -0.09475,1.580345 -0.08893,2.34609 -0.104009,0.451702 -0.587146,0.691508 -1.002445,0.493042 z"/> 3 + </svg>
-1
public/next.svg
··· 1 - <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
-1
public/vercel.svg
··· 1 - <svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
-1
public/window.svg
··· 1 - <svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
+12
vercel.json
··· 1 + { 2 + "headers": [ 3 + { 4 + "source": "/(.*)", 5 + "headers": [ 6 + { "key": "Access-Control-Allow-Origin", "value": "*" }, 7 + { "key": "Access-Control-Allow-Methods", "value": "GET, OPTIONS" }, 8 + { "key": "Access-Control-Allow-Headers", "value": "*" } 9 + ] 10 + } 11 + ] 12 + }