|
8 | 8 | * • `1. ` numbered lists (single level) |
9 | 9 | * • `**bold**`, `*italic*`, `` `code` ``, `[text](url)` inline |
10 | 10 | * • Triple-backtick fenced code blocks |
| 11 | + * • `>` blockquotes: consecutive `>` lines, their content parsed as blocks |
| 12 | + * (so a quote nests lists, code and further quotes). A GitHub alert — |
| 13 | + * `> [!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]`, `[!CAUTION]` on the |
| 14 | + * quote's first line — renders as the same plain quote with the marker |
| 15 | + * dropped. CommonMark's lazy continuation (an unprefixed line continuing a |
| 16 | + * quoted paragraph) is not read: an unprefixed line ends the quote. |
11 | 17 | * |
12 | 18 | * Deliberately NOT a full Markdown engine — we want zero new deps and |
13 | 19 | * predictable output. If a README really needs tables or images, the |
|
29 | 35 | import { RAW_HTML_CLOSING_TAG, RAW_HTML_OPEN_TAG, renderInlineMarkdown } from '@object-ui/components'; |
30 | 36 |
|
31 | 37 | interface Block { |
32 | | - type: 'h1' | 'h2' | 'h3' | 'p' | 'ul' | 'ol' | 'code' | 'html'; |
| 38 | + type: 'h1' | 'h2' | 'h3' | 'p' | 'ul' | 'ol' | 'code' | 'html' | 'quote'; |
33 | 39 | text?: string; |
34 | 40 | items?: string[]; |
35 | 41 | lang?: string; |
| 42 | + /** A blockquote's content, parsed as blocks. */ |
| 43 | + children?: Block[]; |
36 | 44 | } |
37 | 45 |
|
| 46 | +// ── Blockquotes ───────────────────────────────────────────────────────────── |
| 47 | + |
| 48 | +/** A blockquote line: up to three spaces of indent, then `>`. */ |
| 49 | +const QUOTE_LINE = /^ {0,3}>/; |
| 50 | +/** The quote marker and the one optional space after it. */ |
| 51 | +const QUOTE_MARKER = /^ {0,3}> ?/; |
| 52 | +/** |
| 53 | + * GitHub's alert marker, alone on a quote's first line. GitHub matches the |
| 54 | + * five types case-insensitively; anything else in brackets stays text. |
| 55 | + */ |
| 56 | +const ALERT_MARKER = /^\[!(?:NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][ \t]*$/i; |
| 57 | + |
38 | 58 | // ── CommonMark raw-HTML grammar ───────────────────────────────────────────── |
39 | 59 | // The open and closing tag come from `@object-ui/components`, where the inline |
40 | 60 | // "Raw HTML" pass that shares them lives; the block rules below build on them. |
@@ -115,6 +135,19 @@ function parseBlocks(src: string): Block[] { |
115 | 135 | continue; |
116 | 136 | } |
117 | 137 |
|
| 138 | + // Blockquote: the run of `>` lines, unprefixed, parsed as its own blocks. |
| 139 | + // A GitHub alert's marker line is dropped, leaving a plain quote. |
| 140 | + if (QUOTE_LINE.test(line)) { |
| 141 | + const inner: string[] = []; |
| 142 | + while (i < lines.length && QUOTE_LINE.test(lines[i])) { |
| 143 | + inner.push(lines[i].replace(QUOTE_MARKER, '')); |
| 144 | + i++; |
| 145 | + } |
| 146 | + if (ALERT_MARKER.test(inner[0])) inner.shift(); |
| 147 | + blocks.push({ type: 'quote', children: parseBlocks(inner.join('\n')) }); |
| 148 | + continue; |
| 149 | + } |
| 150 | + |
118 | 151 | // Headings |
119 | 152 | const h = /^(#{1,3})\s+(.*)$/.exec(line); |
120 | 153 | if (h) { |
@@ -161,6 +194,7 @@ function parseBlocks(src: string): Block[] { |
161 | 194 | !/^[-*]\s+/.test(lines[i]) && |
162 | 195 | !/^\d+\.\s+/.test(lines[i]) && |
163 | 196 | !lines[i].startsWith('```') && |
| 197 | + !QUOTE_LINE.test(lines[i]) && |
164 | 198 | !htmlBlockRule(lines[i])?.interruptsParagraph |
165 | 199 | ) { |
166 | 200 | buf.push(lines[i]); |
@@ -188,51 +222,87 @@ function renderInline(input: string): React.ReactNode { |
188 | 222 | return renderInlineMarkdown(input, README_INLINE); |
189 | 223 | } |
190 | 224 |
|
191 | | -export function MarkdownText({ source, className }: { source: string; className?: string }) { |
192 | | - const blocks = parseBlocks(source); |
193 | | - return ( |
194 | | - <div className={`flex flex-col gap-4 text-sm leading-relaxed text-foreground/90 ${className ?? ''}`}> |
195 | | - {blocks.map((b, i) => { |
196 | | - switch (b.type) { |
197 | | - case 'h1': |
198 | | - return <h2 key={i} className="mt-2 text-xl font-bold tracking-tight">{renderInline(b.text ?? '')}</h2>; |
199 | | - case 'h2': |
200 | | - return <h3 key={i} className="mt-3 text-base font-semibold tracking-tight">{renderInline(b.text ?? '')}</h3>; |
201 | | - case 'h3': |
202 | | - return <h4 key={i} className="mt-2 text-sm font-semibold tracking-tight text-foreground/80">{renderInline(b.text ?? '')}</h4>; |
203 | | - case 'p': |
204 | | - return <p key={i}>{renderInline(b.text ?? '')}</p>; |
205 | | - case 'ul': |
206 | | - return ( |
207 | | - <ul key={i} className="ml-1 flex list-none flex-col gap-1.5"> |
208 | | - {(b.items ?? []).map((it, j) => ( |
209 | | - <li key={j} className="flex gap-2"> |
210 | | - <span aria-hidden="true" className="mt-1.5 inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-violet-500/70" /> |
211 | | - <span>{renderInline(it)}</span> |
212 | | - </li> |
213 | | - ))} |
214 | | - </ul> |
215 | | - ); |
216 | | - case 'ol': |
217 | | - return ( |
218 | | - <ol key={i} className="ml-5 flex list-decimal flex-col gap-1.5 marker:text-muted-foreground"> |
219 | | - {(b.items ?? []).map((it, j) => ( |
220 | | - <li key={j} className="pl-1">{renderInline(it)}</li> |
221 | | - ))} |
222 | | - </ol> |
223 | | - ); |
224 | | - case 'code': |
225 | | - return ( |
226 | | - <pre key={i} className="overflow-x-auto rounded-lg border bg-muted/40 p-3 text-xs leading-relaxed"> |
227 | | - <code className="font-mono">{b.text}</code> |
228 | | - </pre> |
229 | | - ); |
230 | | - case 'html': |
231 | | - // Skipped: neither rendered nor printed (objectui#12146). |
232 | | - return null; |
233 | | - } |
| 225 | +/** |
| 226 | + * The two places a package's Markdown is read: `base` is the About card's |
| 227 | + * README, `compact` the Versions panel's release notes — a narrow side column |
| 228 | + * in muted small print, where README-sized headings would outweigh the |
| 229 | + * version they annotate. |
| 230 | + */ |
| 231 | +type MarkdownSize = 'base' | 'compact'; |
| 232 | + |
| 233 | +const SIZE_CLASSES: Record<MarkdownSize, { root: string; stack: string; h1: string; h2: string; h3: string }> = { |
| 234 | + base: { |
| 235 | + root: 'text-sm leading-relaxed text-foreground/90', |
| 236 | + stack: 'gap-4', |
| 237 | + h1: 'mt-2 text-xl font-bold tracking-tight', |
| 238 | + h2: 'mt-3 text-base font-semibold tracking-tight', |
| 239 | + h3: 'mt-2 text-sm font-semibold tracking-tight text-foreground/80', |
| 240 | + }, |
| 241 | + compact: { |
| 242 | + root: 'text-xs leading-relaxed text-muted-foreground', |
| 243 | + stack: 'gap-2', |
| 244 | + h1: 'text-sm font-semibold tracking-tight text-foreground', |
| 245 | + h2: 'text-xs font-semibold tracking-tight text-foreground', |
| 246 | + h3: 'text-xs font-medium tracking-tight text-foreground/80', |
| 247 | + }, |
| 248 | +}; |
| 249 | + |
| 250 | +function renderBlocks(blocks: Block[], size: MarkdownSize): React.ReactNode[] { |
| 251 | + const cls = SIZE_CLASSES[size]; |
| 252 | + return blocks.map((b, i) => { |
| 253 | + switch (b.type) { |
| 254 | + case 'h1': |
| 255 | + return <h2 key={i} className={cls.h1}>{renderInline(b.text ?? '')}</h2>; |
| 256 | + case 'h2': |
| 257 | + return <h3 key={i} className={cls.h2}>{renderInline(b.text ?? '')}</h3>; |
| 258 | + case 'h3': |
| 259 | + return <h4 key={i} className={cls.h3}>{renderInline(b.text ?? '')}</h4>; |
| 260 | + case 'p': |
| 261 | + return <p key={i}>{renderInline(b.text ?? '')}</p>; |
| 262 | + case 'ul': |
| 263 | + return ( |
| 264 | + <ul key={i} className="ml-1 flex list-none flex-col gap-1.5"> |
| 265 | + {(b.items ?? []).map((it, j) => ( |
| 266 | + <li key={j} className="flex gap-2"> |
| 267 | + <span aria-hidden="true" className="mt-1.5 inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-violet-500/70" /> |
| 268 | + <span>{renderInline(it)}</span> |
| 269 | + </li> |
| 270 | + ))} |
| 271 | + </ul> |
| 272 | + ); |
| 273 | + case 'ol': |
| 274 | + return ( |
| 275 | + <ol key={i} className="ml-5 flex list-decimal flex-col gap-1.5 marker:text-muted-foreground"> |
| 276 | + {(b.items ?? []).map((it, j) => ( |
| 277 | + <li key={j} className="pl-1">{renderInline(it)}</li> |
| 278 | + ))} |
| 279 | + </ol> |
| 280 | + ); |
| 281 | + case 'code': |
| 282 | + return ( |
| 283 | + <pre key={i} className="overflow-x-auto rounded-lg border bg-muted/40 p-3 text-xs leading-relaxed"> |
| 284 | + <code className="font-mono">{b.text}</code> |
| 285 | + </pre> |
| 286 | + ); |
| 287 | + case 'quote': |
| 288 | + return ( |
| 289 | + <blockquote key={i} className={`flex flex-col ${cls.stack} border-l-2 border-violet-500/40 pl-4 text-muted-foreground`}> |
| 290 | + {renderBlocks(b.children ?? [], size)} |
| 291 | + </blockquote> |
| 292 | + ); |
| 293 | + case 'html': |
| 294 | + // Skipped: neither rendered nor printed (objectui#12146). |
234 | 295 | return null; |
235 | | - })} |
| 296 | + } |
| 297 | + return null; |
| 298 | + }); |
| 299 | +} |
| 300 | + |
| 301 | +export function MarkdownText({ source, className, size = 'base' }: { source: string; className?: string; size?: MarkdownSize }) { |
| 302 | + const cls = SIZE_CLASSES[size]; |
| 303 | + return ( |
| 304 | + <div className={`flex flex-col ${cls.stack} ${cls.root} ${className ?? ''}`}> |
| 305 | + {renderBlocks(parseBlocks(source), size)} |
236 | 306 | </div> |
237 | 307 | ); |
238 | 308 | } |
0 commit comments