271 lines
9.4 KiB
TypeScript
271 lines
9.4 KiB
TypeScript
import type { ReactNode } from 'react';
|
||
import Link from 'next/link';
|
||
import { extractHeadings } from '@/lib/content';
|
||
|
||
/**
|
||
* A tiny in-repo markdown renderer ("MDX-lite") — no markdown/MDX dependency.
|
||
* Supports headings (h1–h3), paragraphs, ordered/unordered lists, blockquotes,
|
||
* horizontal rules, inline code/bold/italic/links and fenced code blocks with a
|
||
* language label and lightweight syntax highlighting.
|
||
*/
|
||
|
||
type Block =
|
||
| { type: 'heading'; level: number; text: string }
|
||
| { type: 'code'; lang: string; code: string }
|
||
| { type: 'list'; ordered: boolean; items: string[] }
|
||
| { type: 'quote'; text: string }
|
||
| { type: 'hr' }
|
||
| { type: 'paragraph'; text: string };
|
||
|
||
function splitBlocks(body: string): Block[] {
|
||
const lines = body.replace(/\r\n/g, '\n').split('\n');
|
||
const blocks: Block[] = [];
|
||
const para: string[] = [];
|
||
const flush = () => {
|
||
if (para.length) {
|
||
blocks.push({ type: 'paragraph', text: para.join(' ').trim() });
|
||
para.length = 0;
|
||
}
|
||
};
|
||
|
||
let i = 0;
|
||
while (i < lines.length) {
|
||
const raw = lines[i];
|
||
const trimmed = raw.trim();
|
||
|
||
if (/^```/.test(trimmed)) {
|
||
flush();
|
||
const lang = trimmed.slice(3).trim();
|
||
const buf: string[] = [];
|
||
i += 1;
|
||
while (i < lines.length && !/^```/.test(lines[i].trim())) {
|
||
buf.push(lines[i]);
|
||
i += 1;
|
||
}
|
||
i += 1;
|
||
blocks.push({ type: 'code', lang, code: buf.join('\n') });
|
||
continue;
|
||
}
|
||
|
||
const heading = /^(#{1,3})\s+(.+)$/.exec(trimmed);
|
||
if (heading) {
|
||
flush();
|
||
blocks.push({ type: 'heading', level: heading[1].length, text: heading[2].trim() });
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (/^(---|\*\*\*|___)\s*$/.test(trimmed)) {
|
||
flush();
|
||
blocks.push({ type: 'hr' });
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (/^>\s?/.test(trimmed)) {
|
||
flush();
|
||
const buf: string[] = [];
|
||
while (i < lines.length && /^>\s?/.test(lines[i].trim())) {
|
||
buf.push(lines[i].trim().replace(/^>\s?/, ''));
|
||
i += 1;
|
||
}
|
||
blocks.push({ type: 'quote', text: buf.join(' ') });
|
||
continue;
|
||
}
|
||
|
||
const unordered = /^[-*+]\s+/.test(trimmed);
|
||
const ordered = /^\d+[.)]\s+/.test(trimmed);
|
||
if (unordered || ordered) {
|
||
flush();
|
||
const items: string[] = [];
|
||
while (i < lines.length) {
|
||
const t = lines[i].trim();
|
||
const um = /^[-*+]\s+(.*)$/.exec(t);
|
||
const om = /^\d+[.)]\s+(.*)$/.exec(t);
|
||
const match = ordered ? om : um;
|
||
if (!match) break;
|
||
items.push(match[1]);
|
||
i += 1;
|
||
}
|
||
blocks.push({ type: 'list', ordered, items });
|
||
continue;
|
||
}
|
||
|
||
if (trimmed === '') {
|
||
flush();
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
para.push(trimmed);
|
||
i += 1;
|
||
}
|
||
flush();
|
||
return blocks;
|
||
}
|
||
|
||
const INLINE_RE = /(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*)|(_[^_]+_)|(\[[^\]]+\]\([^)]+\))/g;
|
||
|
||
function renderInline(text: string, keyPrefix: string): ReactNode[] {
|
||
const nodes: ReactNode[] = [];
|
||
let last = 0;
|
||
let k = 0;
|
||
let m: RegExpExecArray | null;
|
||
INLINE_RE.lastIndex = 0;
|
||
while ((m = INLINE_RE.exec(text)) !== null) {
|
||
if (m.index > last) nodes.push(text.slice(last, m.index));
|
||
const token = m[0];
|
||
const key = `${keyPrefix}-${k++}`;
|
||
if (token.startsWith('`')) {
|
||
nodes.push(
|
||
<code key={key} className="rounded bg-black/5 px-1.5 py-0.5 font-mono text-[0.85em] dark:bg-white/10">
|
||
{token.slice(1, -1)}
|
||
</code>,
|
||
);
|
||
} else if (token.startsWith('**')) {
|
||
nodes.push(<strong key={key}>{token.slice(2, -2)}</strong>);
|
||
} else if (token.startsWith('[')) {
|
||
const link = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(token);
|
||
if (link && link[2].startsWith('/')) {
|
||
nodes.push(
|
||
<Link key={key} href={link[2]} className="text-brand-600 underline underline-offset-2 dark:text-brand-400">
|
||
{link[1]}
|
||
</Link>,
|
||
);
|
||
} else if (link) {
|
||
nodes.push(
|
||
<a key={key} href={link[2]} className="text-brand-600 underline underline-offset-2 dark:text-brand-400" rel="noreferrer">
|
||
{link[1]}
|
||
</a>,
|
||
);
|
||
} else {
|
||
nodes.push(token);
|
||
}
|
||
} else {
|
||
nodes.push(<em key={key}>{token.slice(1, -1)}</em>);
|
||
}
|
||
last = m.index + token.length;
|
||
}
|
||
if (last < text.length) nodes.push(text.slice(last));
|
||
return nodes;
|
||
}
|
||
|
||
const KEYWORDS = new Set([
|
||
'const', 'let', 'var', 'function', 'return', 'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'break', 'continue',
|
||
'class', 'extends', 'implements', 'new', 'this', 'super', 'import', 'from', 'export', 'default', 'await', 'async',
|
||
'type', 'interface', 'enum', 'public', 'private', 'protected', 'readonly', 'static', 'void', 'null', 'undefined',
|
||
'true', 'false', 'try', 'catch', 'finally', 'throw', 'in', 'of', 'as', 'yield', 'def', 'elif', 'None', 'True', 'False',
|
||
'self', 'lambda', 'pass', 'raise', 'with', 'end', 'and', 'or', 'not', 'nil', 'func', 'package', 'struct', 'go', 'defer',
|
||
'SELECT', 'FROM', 'WHERE', 'INSERT', 'UPDATE', 'DELETE', 'CREATE', 'TABLE', 'JOIN', 'GROUP', 'ORDER', 'BY',
|
||
]);
|
||
|
||
const HASH_LANGS = new Set(['bash', 'sh', 'shell', 'zsh', 'yaml', 'yml', 'python', 'py', 'ruby', 'rb', 'toml', 'ini', 'conf', 'dockerfile', 'make']);
|
||
const CODE_HASH = /(#.*|\/\/.*|\/\*[\s\S]*?\*\/)|("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)|(\b\d+(?:\.\d+)?\b)|(\b[A-Za-z_$][A-Za-z0-9_$]*\b)/g;
|
||
const CODE_SLASH = /(\/\/.*|\/\*[\s\S]*?\*\/)|("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)|(\b\d+(?:\.\d+)?\b)|(\b[A-Za-z_$][A-Za-z0-9_$]*\b)/g;
|
||
|
||
const TONE = {
|
||
comment: 'text-slate-500 italic',
|
||
string: 'text-emerald-600 dark:text-emerald-400',
|
||
number: 'text-amber-600 dark:text-amber-400',
|
||
keyword: 'text-violet-600 dark:text-violet-400',
|
||
};
|
||
|
||
function highlight(code: string, lang: string, keyPrefix: string): ReactNode[] {
|
||
const re = HASH_LANGS.has(lang.toLowerCase()) ? CODE_HASH : CODE_SLASH;
|
||
const nodes: ReactNode[] = [];
|
||
let last = 0;
|
||
let k = 0;
|
||
let m: RegExpExecArray | null;
|
||
re.lastIndex = 0;
|
||
while ((m = re.exec(code)) !== null) {
|
||
if (m.index > last) nodes.push(code.slice(last, m.index));
|
||
const key = `${keyPrefix}-${k++}`;
|
||
if (m[1]) nodes.push(<span key={key} className={TONE.comment}>{m[1]}</span>);
|
||
else if (m[2]) nodes.push(<span key={key} className={TONE.string}>{m[2]}</span>);
|
||
else if (m[3]) nodes.push(<span key={key} className={TONE.number}>{m[3]}</span>);
|
||
else if (KEYWORDS.has(m[4])) nodes.push(<span key={key} className={TONE.keyword}>{m[4]}</span>);
|
||
else nodes.push(m[4]);
|
||
last = m.index + m[0].length;
|
||
}
|
||
if (last < code.length) nodes.push(code.slice(last));
|
||
return nodes;
|
||
}
|
||
|
||
export function Markdown({ body }: { body: string }) {
|
||
const headings = extractHeadings(body);
|
||
const blocks = splitBlocks(body);
|
||
let headingCursor = 0;
|
||
|
||
return (
|
||
<div className="content-prose">
|
||
{blocks.map((block, i) => {
|
||
const key = `b${i}`;
|
||
if (block.type === 'code') {
|
||
return (
|
||
<figure key={key} className="my-6 overflow-hidden rounded-xl border" style={{ borderColor: 'var(--border)' }}>
|
||
{block.lang ? (
|
||
<figcaption className="muted border-b px-4 py-2 font-mono text-xs uppercase" style={{ borderColor: 'var(--border)', background: 'var(--surface-muted)' }}>
|
||
{block.lang}
|
||
</figcaption>
|
||
) : null}
|
||
<pre className="overflow-x-auto px-4 py-3 text-sm leading-relaxed" style={{ background: 'var(--surface-muted)' }}>
|
||
<code className="font-mono">{highlight(block.code, block.lang, key)}</code>
|
||
</pre>
|
||
</figure>
|
||
);
|
||
}
|
||
if (block.type === 'heading') {
|
||
if (block.level === 1) {
|
||
return (
|
||
<h1 key={key} className="mt-10 text-2xl font-bold tracking-tight">
|
||
{block.text}
|
||
</h1>
|
||
);
|
||
}
|
||
const anchor = headings[headingCursor++]?.id;
|
||
return block.level === 2 ? (
|
||
<h2 key={key} id={anchor} className="mt-10 scroll-mt-24 text-xl font-semibold tracking-tight">
|
||
{block.text}
|
||
</h2>
|
||
) : (
|
||
<h3 key={key} id={anchor} className="mt-8 scroll-mt-24 text-lg font-semibold">
|
||
{block.text}
|
||
</h3>
|
||
);
|
||
}
|
||
if (block.type === 'list') {
|
||
const cls = 'mt-4 list-outside space-y-1 pl-6';
|
||
return block.ordered ? (
|
||
<ol key={key} className={`${cls} list-decimal`}>
|
||
{block.items.map((item, j) => (
|
||
<li key={`${key}-${j}`}>{renderInline(item, `${key}-${j}`)}</li>
|
||
))}
|
||
</ol>
|
||
) : (
|
||
<ul key={key} className={`${cls} list-disc`}>
|
||
{block.items.map((item, j) => (
|
||
<li key={`${key}-${j}`}>{renderInline(item, `${key}-${j}`)}</li>
|
||
))}
|
||
</ul>
|
||
);
|
||
}
|
||
if (block.type === 'quote') {
|
||
return (
|
||
<blockquote key={key} className="muted my-6 border-l-4 pl-4 italic" style={{ borderColor: 'var(--color-brand-400)' }}>
|
||
{renderInline(block.text, key)}
|
||
</blockquote>
|
||
);
|
||
}
|
||
if (block.type === 'hr') {
|
||
return <hr key={key} className="my-8 border-t" style={{ borderColor: 'var(--border)' }} />;
|
||
}
|
||
return (
|
||
<p key={key} className="muted mt-4 leading-relaxed">
|
||
{renderInline(block.text, key)}
|
||
</p>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|