templates2.Content.Next/components/content/Markdown.tsx
w4c-templates 3caefb8833
Some checks are pending
CI / quality (push) Waiting to run
chore(template): publish Content.Next
2026-10-11 23:29:57 +03:00

271 lines
9.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}