SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
3.7 KB · 72 lines tsx
Raw Blame History
1'use client';23import Link from 'next/link';4import { Fragment, type ReactNode } from 'react';56/**7 * Tiny dependency-free markdown renderer for assistant answers: paragraphs, headings, bullet and8 * numbered lists, pipe tables, bold/italic/code and links (internal links use next/link).9 * Not a full CommonMark implementation — enough for terminal-style research answers.10 */11export function Markdown({ text }: { text: string }) {12  const blocks = splitBlocks(text);13  return <div className="space-y-2 text-[13px] leading-relaxed">{blocks.map((b, i) => <Fragment key={i}>{renderBlock(b)}</Fragment>)}</div>;14}1516function splitBlocks(text: string): string[] {17  return text.replace(/\r\n/g, '\n').split(/\n{2,}/).map((b) => b.trim()).filter(Boolean);18}1920function renderBlock(block: string): ReactNode {21  const lines = block.split('\n');22  if (lines.length >= 2 && lines.every((l) => l.trim().startsWith('|'))) return renderTable(lines);23  if (lines.every((l) => /^\s*[-*•]\s+/.test(l))) return <ul className="list-disc space-y-0.5 pl-5">{lines.map((l, i) => <li key={i}>{inline(l.replace(/^\s*[-*•]\s+/, ''))}</li>)}</ul>;24  if (lines.every((l) => /^\s*\d+[.)]\s+/.test(l))) return <ol className="list-decimal space-y-0.5 pl-5">{lines.map((l, i) => <li key={i}>{inline(l.replace(/^\s*\d+[.)]\s+/, ''))}</li>)}</ol>;25  const h = block.match(/^(#{1,4})\s+(.*)$/);26  if (h && lines.length === 1) {27    const level = h[1]!.length;28    const cls = level <= 2 ? 'text-sm font-semibold' : 'text-[13px] font-semibold';29    return <p className={cls}>{inline(h[2]!)}</p>;30  }31  if (block.startsWith('```')) return <pre className="overflow-x-auto rounded-md bg-inset p-2 font-mono text-[11px]">{block.replace(/^```\w*\n?/, '').replace(/```$/, '')}</pre>;32  return <p>{lines.map((l, i) => <Fragment key={i}>{i > 0 ? <br /> : null}{inline(l)}</Fragment>)}</p>;33}3435function renderTable(lines: string[]): ReactNode {36  const rows = lines.filter((l) => !/^\s*\|?\s*:?-{2,}/.test(l)).map((l) => l.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim()));37  const [head, ...body] = rows;38  if (!head) return null;39  return (40    <div className="overflow-x-auto">41      <table className="w-full border-collapse text-[12px]">42        <thead>43          <tr>{head.map((c, i) => <th key={i} className="border-b border-border px-2 py-1 text-left text-[10px] font-semibold uppercase tracking-wider text-subtle">{inline(c)}</th>)}</tr>44        </thead>45        <tbody>46          {body.map((r, i) => (47            <tr key={i}>{r.map((c, j) => <td key={j} className={`border-b border-border px-2 py-1 ${/^[\s$€£¥+\-−]?[\d.,]+%?$/.test(c) ? 'num text-right' : ''}`}>{inline(c)}</td>)}</tr>48          ))}49        </tbody>50      </table>51    </div>52  );53}5455const INLINE = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`|\[[^\]]+\]\([^)]+\))/g;5657function inline(s: string): ReactNode {58  const parts = s.split(INLINE).filter((p) => p !== '');59  return parts.map((p, i) => {60    if (p.startsWith('**') && p.endsWith('**')) return <strong key={i}>{p.slice(2, -2)}</strong>;61    if (p.startsWith('*') && p.endsWith('*') && p.length > 2) return <em key={i}>{p.slice(1, -1)}</em>;62    if (p.startsWith('`') && p.endsWith('`')) return <code key={i} className="rounded-sm bg-inset px-1 font-mono text-[11px]">{p.slice(1, -1)}</code>;63    const m = p.match(/^\[([^\]]+)\]\(([^)]+)\)$/);64    if (m) {65      const href = m[2]!;66      const cls = 'font-medium underline decoration-border-strong underline-offset-2 hover:decoration-fg';67      return href.startsWith('/') ? <Link key={i} href={href} className={cls}>{m[1]}</Link> : <a key={i} href={href} target="_blank" rel="noreferrer noopener" className={cls}>{m[1]}</a>;68    }69    return <Fragment key={i}>{p}</Fragment>;70  });71}72