import { useMemo } from "react";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
/**
* Lightweight, dependency-free markdown renderer for registry-block internals.
*
* Content's registry blocks (the core dev-doc / OpenAPI library) render their
* own structured chrome and only defer short prose strings — an endpoint
* description, a file-tree note, an annotated-code note — to
* `ctx.renderMarkdown`. Those strings are simple inline markdown (bold, italic,
* inline code, links) plus the occasional fenced code block. Rather than pull in
* `react-markdown` + `remark-gfm` (not a content dependency), this renders that
* narrow subset directly.
*
* It is intentionally NOT the document editor: block prose is small, read-mostly,
* and lives inside the block's own surface. The authoritative document prose
* still round-trips through `docToNfm` / `nfmToDoc` in `VisualEditor`.
*/
type InlineSegment =
| { kind: "text"; value: string }
| { kind: "code"; value: string }
| { kind: "strong"; value: string }
| { kind: "em"; value: string }
| { kind: "link"; value: string; href: string };
/**
* Parse a single line of inline markdown into styled segments. Handles inline
* code (`` `x` ``), bold (`**x**`), italic (`*x*` / `_x_`), and links
* (`[label](href)`). Inline code wins first so markup inside backticks stays
* literal. Anything unmatched is plain text.
*/
function parseInline(line: string): InlineSegment[] {
const segments: InlineSegment[] = [];
// Order matters: code first (its contents are literal), then links, then
// bold (`**`/`__`) before italic (`*`/`_`) so `**` is not mis-split.
const pattern =
/(`[^`]+`)|(\[[^\]]+\]\([^)]+\))|(\*\*[^*]+\*\*|__[^_]+__)|(\*[^*]+\*|_[^_]+_)/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = pattern.exec(line)) !== null) {
if (match.index > lastIndex) {
segments.push({
kind: "text",
value: line.slice(lastIndex, match.index),
});
}
const token = match[0];
if (match[1]) {
segments.push({ kind: "code", value: token.slice(1, -1) });
} else if (match[2]) {
const linkMatch = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(token);
if (linkMatch) {
segments.push({
kind: "link",
value: linkMatch[1],
href: linkMatch[2],
});
} else {
segments.push({ kind: "text", value: token });
}
} else if (match[3]) {
segments.push({ kind: "strong", value: token.slice(2, -2) });
} else if (match[4]) {
segments.push({ kind: "em", value: token.slice(1, -1) });
}
lastIndex = pattern.lastIndex;
}
if (lastIndex < line.length) {
segments.push({ kind: "text", value: line.slice(lastIndex) });
}
return segments;
}
function renderInline(line: string, keyPrefix: string): ReactNode[] {
return parseInline(line).map((segment, index) => {
const key = `${keyPrefix}-${index}`;
switch (segment.kind) {
case "code":
return (
{segment.value}
);
case "strong":
return {segment.value};
case "em":
return {segment.value};
case "link":
return (
{segment.value}
);
default:
return {segment.value};
}
});
}
type Block =
| { kind: "heading"; level: number; text: string }
| { kind: "code"; code: string }
| { kind: "ul"; items: string[] }
| { kind: "ol"; items: string[] }
| { kind: "p"; lines: string[] };
/** Split markdown source into coarse block-level chunks. */
function parseBlocks(markdown: string): Block[] {
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
const blocks: Block[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
const trimmed = line.trim();
if (!trimmed) {
i += 1;
continue;
}
// Fenced code block.
const fence = /^```(.*)$/.exec(trimmed);
if (fence) {
const code: string[] = [];
i += 1;
while (i < lines.length && !/^```/.test(lines[i].trim())) {
code.push(lines[i]);
i += 1;
}
i += 1; // closing fence
blocks.push({ kind: "code", code: code.join("\n") });
continue;
}
// Heading.
const heading = /^(#{1,6})\s+(.*)$/.exec(trimmed);
if (heading) {
blocks.push({
kind: "heading",
level: heading[1].length,
text: heading[2],
});
i += 1;
continue;
}
// Unordered list.
if (/^[-*+]\s+/.test(trimmed)) {
const items: string[] = [];
while (i < lines.length && /^[-*+]\s+/.test(lines[i].trim())) {
items.push(lines[i].trim().replace(/^[-*+]\s+/, ""));
i += 1;
}
blocks.push({ kind: "ul", items });
continue;
}
// Ordered list.
if (/^\d+\.\s+/.test(trimmed)) {
const items: string[] = [];
while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) {
items.push(lines[i].trim().replace(/^\d+\.\s+/, ""));
i += 1;
}
blocks.push({ kind: "ol", items });
continue;
}
// Paragraph: consume consecutive non-blank, non-structural lines.
const paragraph: string[] = [];
while (i < lines.length) {
const next = lines[i];
const nextTrimmed = next.trim();
if (
!nextTrimmed ||
/^```/.test(nextTrimmed) ||
/^#{1,6}\s+/.test(nextTrimmed) ||
/^[-*+]\s+/.test(nextTrimmed) ||
/^\d+\.\s+/.test(nextTrimmed)
) {
break;
}
paragraph.push(next.trim());
i += 1;
}
blocks.push({ kind: "p", lines: paragraph });
}
return blocks;
}
export function ContentBlockMarkdown({
markdown,
className,
}: {
markdown: string;
className?: string;
}) {
const blocks = useMemo(() => parseBlocks(markdown ?? ""), [markdown]);
return (
{block.code}
);
case "ul":
return (
{block.lines.map((line, lineIndex) => (
{renderInline(line, `${key}-${lineIndex}`)}
{lineIndex < block.lines.length - 1 ?
: null}
))}