Standard.site landing page built in Next.js
0
fork

Configure Feed

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

Add MDX-to-markdown parser with table and component transforms

+269 -2
+269 -2
app/api/docs/markdown/[...slug]/route.ts
··· 1 1 import { NextResponse } from 'next/server' 2 2 import { readFile } from 'node:fs/promises' 3 - import { join, resolve } from 'node:path' 3 + import { resolve } from 'node:path' 4 4 5 5 // Map of slugs to their file paths 6 6 const docsFiles: Record<string, string> = { ··· 36 36 } 37 37 38 38 const filePath = resolve(process.cwd(), 'content', 'docs', fileName) 39 - const markdown = await readFile(filePath, 'utf-8') 39 + const raw = await readFile(filePath, 'utf-8') 40 + const markdown = await mdxToMarkdown(raw) 40 41 41 42 return new NextResponse(markdown, { 42 43 headers: { ··· 48 49 return new NextResponse('Internal server error', { status: 500 }) 49 50 } 50 51 } 52 + 53 + async function mdxToMarkdown(content: string): Promise<string> { 54 + let result = content 55 + 56 + // Remove import lines 57 + result = result.replace(/^import\s+.*$/gm, '') 58 + 59 + // Replace <StandardSite /> with plain text 60 + result = result.replace(/<StandardSite\s*\/>/g, 'Standard.site') 61 + 62 + // Replace <LinkCard> components with markdown links, fetching OG titles 63 + const linkCardRegex = /<LinkCard\s+([\s\S]*?)\/>/g 64 + const linkCards: { match: string; url: string; title: string }[] = [] 65 + 66 + for (const m of content.matchAll(linkCardRegex)) { 67 + const attrs = m[1] 68 + const url = attrs.match(/url=["']([^"']*)["']/)?.[1] ?? '' 69 + const title = attrs.match(/title=["']([^"']*)["']/)?.[1] ?? '' 70 + linkCards.push({ match: m[0], url, title }) 71 + } 72 + 73 + // Fetch OG titles in parallel for cards without explicit titles 74 + const titles = await Promise.all( 75 + linkCards.map(async (card) => { 76 + if (card.title) return card.title 77 + return await fetchOgTitle(card.url) 78 + }) 79 + ) 80 + 81 + for (let i = 0; i < linkCards.length; i++) { 82 + result = result.replace(linkCards[i].match, `[${titles[i]}](${linkCards[i].url})`) 83 + } 84 + 85 + // Replace <Table> components with markdown tables 86 + // The closing /> is always on its own line, so we anchor to that to avoid 87 + // matching /> inside JSX fragments like </> 88 + result = result.replace(/<Table\s+([\s\S]*?)\n\/>/g, (_match, body) => { 89 + return parseTable(body) 90 + }) 91 + 92 + // Clean up extra blank lines 93 + result = result.replace(/\n{3,}/g, '\n\n') 94 + 95 + return result.trim() + '\n' 96 + } 97 + 98 + async function fetchOgTitle(url: string): Promise<string> { 99 + try { 100 + const res = await fetch(url, { 101 + next: { revalidate: 86400 }, 102 + headers: { 103 + 'User-Agent': 'Mozilla/5.0 (compatible; Standard.site/1.0; +https://standard.site)' 104 + }, 105 + signal: AbortSignal.timeout(5000), 106 + }) 107 + 108 + if (!res.ok) return url 109 + 110 + const html = await res.text() 111 + const ogTitle = 112 + html.match(/<meta[^>]*property=["']og:title["'][^>]*content=["']([^"']*)["']/i)?.[1] || 113 + html.match(/<meta[^>]*content=["']([^"']*)["'][^>]*property=["']og:title["']/i)?.[1] || 114 + html.match(/<title[^>]*>([^<]*)<\/title>/i)?.[1] 115 + 116 + return ogTitle?.trim() || url 117 + } catch { 118 + return url 119 + } 120 + } 121 + 122 + function parseTable(body: string): string { 123 + const headersMatch = body.match(/headers=\{(\[.*?\])}/) 124 + 125 + const rowsMatch = body.match(/rows=\{(\[[\s\S]*)\}\s*$/) 126 + 127 + if (!headersMatch || !rowsMatch) return body 128 + 129 + let headers: string[] 130 + try { 131 + // Convert single quotes to double quotes for JSON parsing 132 + headers = JSON.parse(headersMatch[1].replace(/'/g, '"')) 133 + } catch { 134 + return body 135 + } 136 + 137 + const rows = parseRows(rowsMatch[1]) 138 + 139 + // Build markdown table 140 + const headerRow = '| ' + headers.join(' | ') + ' |' 141 + const separator = '| ' + headers.map(() => '---').join(' | ') + ' |' 142 + const dataRows = rows.map(row => 143 + '| ' + row.map(cell => cell.replace(/\|/g, '\\|')).join(' | ') + ' |' 144 + ) 145 + 146 + return [headerRow, separator, ...dataRows].join('\n') 147 + } 148 + 149 + function parseRows(raw: string): string[][] { 150 + const rows: string[][] = [] 151 + 152 + // Match each row array: ['col1', 'col2', ...] 153 + // Rows can contain strings or JSX expressions (<>...</>, <code>...</code>, etc.) 154 + const rowRegex = /\[([^\]]*(?:\[[^\]]*\])*[^\]]*(?:<[^>]*>[^<]*<\/[^>]*>)*[^\]]*)\]/g 155 + 156 + // Strip outer brackets 157 + const inner = raw.trim().replace(/^\[/, '').replace(/\]$/, '') 158 + 159 + // Split into individual row arrays by matching balanced brackets 160 + const rowStrings = splitRows(inner) 161 + 162 + for (const rowStr of rowStrings) { 163 + const cells = splitCells(rowStr) 164 + if (cells.length > 0) { 165 + rows.push(cells.map(cell => jsxToPlainText(cell.trim()))) 166 + } 167 + } 168 + 169 + return rows 170 + } 171 + 172 + function splitRows(content: string): string[] { 173 + const rows: string[] = [] 174 + let depth = 0 175 + let current = '' 176 + let inString = false 177 + let stringChar = '' 178 + let inJsx = false 179 + 180 + for (let i = 0; i < content.length; i++) { 181 + const char = content[i] 182 + const prev = content[i - 1] 183 + 184 + if (inString) { 185 + current += char 186 + if (char === stringChar && prev !== '\\') { 187 + inString = false 188 + } 189 + continue 190 + } 191 + 192 + // Track JSX fragments: <> and </> 193 + if (char === '<' && content[i + 1] === '>') { 194 + inJsx = true 195 + current += char 196 + continue 197 + } 198 + if (char === '<' && content[i + 1] === '/' && content[i + 2] === '>') { 199 + inJsx = false 200 + current += char 201 + continue 202 + } 203 + 204 + // Only treat quotes as string delimiters outside JSX content 205 + if (!inJsx && (char === "'" || char === '"' || char === '`')) { 206 + inString = true 207 + stringChar = char 208 + current += char 209 + continue 210 + } 211 + 212 + if (char === '[') { 213 + if (depth > 0) current += char 214 + depth++ 215 + } else if (char === ']') { 216 + depth-- 217 + if (depth === 0) { 218 + rows.push(current.trim()) 219 + current = '' 220 + } else { 221 + current += char 222 + } 223 + } else if (depth > 0) { 224 + current += char 225 + } 226 + } 227 + 228 + return rows 229 + } 230 + 231 + function splitCells(row: string): string[] { 232 + const cells: string[] = [] 233 + let current = '' 234 + let depth = 0 235 + let inString = false 236 + let stringChar = '' 237 + let angleBracketDepth = 0 238 + let inJsx = false 239 + 240 + for (let i = 0; i < row.length; i++) { 241 + const char = row[i] 242 + const prev = row[i - 1] 243 + 244 + if (inString) { 245 + current += char 246 + if (char === stringChar && prev !== '\\') { 247 + inString = false 248 + } 249 + continue 250 + } 251 + 252 + // Track JSX fragments: <> and </> 253 + if (char === '<' && row[i + 1] === '>') { 254 + inJsx = true 255 + angleBracketDepth++ 256 + current += char 257 + continue 258 + } 259 + if (char === '<' && row[i + 1] === '/' && row[i + 2] === '>') { 260 + inJsx = false 261 + current += char 262 + continue 263 + } 264 + 265 + // Only treat quotes as string delimiters outside JSX content 266 + if (!inJsx && (char === "'" || char === '"' || char === '`')) { 267 + inString = true 268 + stringChar = char 269 + current += char 270 + continue 271 + } 272 + 273 + if (char === '{') { depth++; current += char; continue } 274 + if (char === '}') { depth--; current += char; continue } 275 + if (char === '<') { angleBracketDepth++; current += char; continue } 276 + if (char === '>') { angleBracketDepth--; current += char; continue } 277 + 278 + if (char === ',' && depth === 0 && angleBracketDepth === 0) { 279 + cells.push(current.trim()) 280 + current = '' 281 + continue 282 + } 283 + 284 + current += char 285 + } 286 + 287 + if (current.trim()) cells.push(current.trim()) 288 + 289 + return cells 290 + } 291 + 292 + function jsxToPlainText(cell: string): string { 293 + // Remove wrapping quotes from plain strings 294 + if (/^['"].*['"]$/.test(cell)) { 295 + return cell.slice(1, -1) 296 + } 297 + 298 + // Handle JSX expressions wrapped in {<>...</>} or <>...</> 299 + let text = cell 300 + 301 + // Strip outer JSX expression wrapper: {<>...</>} 302 + text = text.replace(/^\{?<>/, '').replace(/<\/>\}?$/, '') 303 + 304 + // Convert <code>...</code> to backtick-wrapped text 305 + text = text.replace(/<code>([^<]*)<\/code>/g, '`$1`') 306 + 307 + // Convert <a href="...">...</a> to markdown links 308 + text = text.replace(/<a\s+href=["']([^"']*)["'][^>]*>([^<]*)<\/a>/g, '[$2]($1)') 309 + 310 + // Strip any remaining JSX tags 311 + text = text.replace(/<[^>]+>/g, '') 312 + 313 + // Clean up whitespace 314 + text = text.replace(/\s+/g, ' ').trim() 315 + 316 + return text 317 + }