/** * FrontmatterDisplay - Beautiful metadata display for YAML frontmatter. * * Parses frontmatter from markdown content and displays key-value pairs * in a refined grid with special formatting for URLs, dates, durations, etc. * Matches the "Scholarly Dusk" design system. */ import { CalendarIcon, ClockIcon, ExternalLinkIcon, HashIcon, LinkIcon, TagIcon, UserIcon, } from "lucide-react"; import { memo, useMemo, type FC, type ReactNode } from "react"; import { cn } from "../lib/utils"; import { Badge } from "./ui/badge"; // ───────────────────────────────────────────────────────────────────────────── // Frontmatter Parsing // ───────────────────────────────────────────────────────────────────────────── interface ParsedFrontmatter { data: Record; body: string; } const INLINE_ARRAY_REGEX = /^\[([^\]]*)\]$/; function normalizeScalar(value: string): unknown { const trimmed = value.trim(); if ( (trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'")) ) { return trimmed.slice(1, -1); } if (/^-?\d+\.?\d*$/.test(trimmed)) { return Number.parseFloat(trimmed); } if (trimmed === "true") return true; if (trimmed === "false") return false; return trimmed; } function parseYamlFrontmatterBlock(yamlBlock: string): Record { const data: Record = {}; const lines = yamlBlock.split("\n"); for (let i = 0; i < lines.length; i++) { const line = lines[i]?.trimEnd(); if (!line || line.trimStart().startsWith("#")) { continue; } const match = line.match(/^([^:]+):\s*(.*)$/); if (!match) { continue; } const [, rawKey = "", rawValue = ""] = match; const key = rawKey.trim(); const value = rawValue.trim(); const inlineArrayMatch = INLINE_ARRAY_REGEX.exec(value); if (inlineArrayMatch?.[1]) { data[key] = inlineArrayMatch[1] .split(",") .map((item) => normalizeScalar(item)) .filter((item) => item !== ""); continue; } if (value.length === 0) { const arrayItems: unknown[] = []; let multilineValue: string[] = []; let multilineMode = false; for (let j = i + 1; j < lines.length; j++) { const nextLine = lines[j]; if (nextLine === undefined) { break; } const trimmedNext = nextLine.trimEnd(); if (!trimmedNext) { if (multilineMode) { multilineValue.push(""); } continue; } if (!/^\s/.test(nextLine)) { break; } const arrayMatch = nextLine.match(/^\s*-\s*(.*)$/); if (arrayMatch) { arrayItems.push(normalizeScalar(arrayMatch[1] ?? "")); i = j; continue; } multilineMode = true; multilineValue.push(nextLine.trim()); i = j; } if (arrayItems.length > 0) { data[key] = arrayItems; } else if (multilineValue.length > 0) { data[key] = multilineValue.join("\n"); } else { data[key] = ""; } continue; } data[key] = normalizeScalar(value); } return data; } /** * Parse YAML frontmatter from markdown content. * Returns empty data if no frontmatter found. */ export function parseFrontmatter(content: string): ParsedFrontmatter { const trimmed = content.trimStart(); // Check for frontmatter delimiter if (!trimmed.startsWith("---")) { return { data: {}, body: content }; } // Find closing delimiter const endIndex = trimmed.indexOf("\n---", 3); if (endIndex === -1) { return { data: {}, body: content }; } const yamlBlock = trimmed.slice(4, endIndex).trim(); const body = trimmed.slice(endIndex + 4).trimStart(); if (typeof Bun !== "undefined" && Bun.YAML) { try { const parsed = Bun.YAML.parse(yamlBlock); return { data: parsed && typeof parsed === "object" ? (parsed as Record) : {}, body, }; } catch { // Fall through to the browser-safe parser below. } } return { data: parseYamlFrontmatterBlock(yamlBlock), body, }; } // ───────────────────────────────────────────────────────────────────────────── // Value Formatting // ───────────────────────────────────────────────────────────────────────────── /** Check if string looks like a URL */ function isUrl(value: string): boolean { return /^https?:\/\//.test(value); } /** Check if string looks like a date */ function isDate(value: string): boolean { if (!/^\d{4}-\d{2}-\d{2}/.test(value)) return false; return !Number.isNaN(Date.parse(value)); } /** Check if key suggests a duration field */ function isDurationKey(key: string): boolean { return /duration|time|length/i.test(key); } /** Check if key suggests a person/author field */ function isPersonKey(key: string): boolean { return /author|guest|host|creator|by/i.test(key); } /** Check if key suggests a count/number field */ function isCountKey(key: string): boolean { return /count|views|likes|subscribers|followers/i.test(key); } /** Check if key suggests tags */ function isTagsKey(key: string): boolean { return /tags?|categories|topics/i.test(key); } /** Format a duration in seconds to human readable */ function formatDuration(seconds: number): string { const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); if (h > 0) { return `${h}h ${m}m`; } if (m > 0) { return `${m}m ${s}s`; } return `${s}s`; } /** Format large numbers with K/M suffix */ function formatCount(num: number): string { if (num >= 1_000_000) { return `${(num / 1_000_000).toFixed(1)}M`; } if (num >= 1_000) { return `${(num / 1_000).toFixed(1)}K`; } return num.toLocaleString(); } /** Format a date string nicely */ function formatDate(dateStr: string): string { const time = Date.parse(dateStr); if (Number.isNaN(time)) return dateStr; try { return new Date(time).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric", }); } catch { return dateStr; } } /** Get icon for a key */ function getKeyIcon(key: string): ReactNode { const lowerKey = key.toLowerCase(); if (isPersonKey(key)) { return ; } if (isDurationKey(key)) { return ; } if (isCountKey(key)) { return ; } if (isTagsKey(key)) { return ; } if (lowerKey.includes("url") || lowerKey.includes("link")) { return ; } if (lowerKey.includes("date") || lowerKey.includes("published")) { return ; } return null; } /** Format a key name for display */ function formatKey(key: string): string { return key .replace(/_/g, " ") .replace(/([a-z])([A-Z])/g, "$1 $2") .replace(/\b\w/g, (c) => c.toUpperCase()); } // ───────────────────────────────────────────────────────────────────────────── // Components // ───────────────────────────────────────────────────────────────────────────── interface ValueDisplayProps { keyName: string; value: unknown; } const ValueDisplay: FC = ({ keyName, value }) => { // Handle arrays (tags, etc.) if (Array.isArray(value)) { const normalizedValues = value.filter( (item): item is string | number => typeof item === "string" || typeof item === "number" ); if (normalizedValues.length === 0) { return null; } if (isTagsKey(keyName)) { return (
{normalizedValues.map((item, i) => ( {String(item)} ))}
); } if ( normalizedValues.every((item) => typeof item === "string" && isUrl(item)) ) { return (
{normalizedValues.map((item, i) => ( {String(item)} ))}
); } return (
{normalizedValues.map((item, i) => ( {String(item)} ))}
); } // Handle URLs if (typeof value === "string" && isUrl(value)) { return ( {value} ); } // Handle dates if (typeof value === "string" && isDate(value)) { return {formatDate(value)}; } // Handle duration seconds if ( typeof value === "number" && isDurationKey(keyName) && keyName.toLowerCase().includes("second") ) { return ( {formatDuration(value)} ); } // Handle counts if (typeof value === "number" && isCountKey(keyName)) { return ( {formatCount(value)} ); } // Handle multiline text (descriptions) if (typeof value === "string" && value.includes("\n")) { return {value}; } // Handle long text if (typeof value === "string" && value.length > 100) { return {value}; } // Default display return {String(value)}; }; interface FrontmatterItemProps { keyName: string; value: unknown; isLarge?: boolean; } const FrontmatterItem: FC = ({ keyName, value, isLarge, }) => { const icon = getKeyIcon(keyName); return (
{icon} {formatKey(keyName)}
); }; // ───────────────────────────────────────────────────────────────────────────── // Main Component // ───────────────────────────────────────────────────────────────────────────── export interface FrontmatterDisplayProps { /** Raw markdown content with frontmatter */ content: string; /** Additional CSS classes */ className?: string; /** Callback with body content (frontmatter stripped) */ onBodyExtracted?: (body: string) => void; } /** * Displays YAML frontmatter in a beautiful metadata grid. * Automatically detects and formats URLs, dates, durations, counts, etc. */ export const FrontmatterDisplay = memo( ({ content, className }: FrontmatterDisplayProps) => { const { data } = useMemo(() => parseFrontmatter(content), [content]); const entries = Object.entries(data).filter( ([, value]) => value !== undefined && value !== null && value !== "" ); if (entries.length === 0) { return null; } // Determine which fields should span full width const isLargeField = (key: string, value: unknown): boolean => { if (typeof value === "string") { return value.length > 80 || value.includes("\n"); } if (key.toLowerCase().includes("description")) { return true; } return false; }; // Sort: small fields first (for grid layout), large fields last const sortedEntries = [...entries].sort(([keyA, valA], [keyB, valB]) => { const aLarge = isLargeField(keyA, valA); const bLarge = isLargeField(keyB, valB); if (aLarge && !bLarge) return 1; if (!aLarge && bLarge) return -1; return 0; }); return (
{sortedEntries.map(([key, value]) => ( ))}
); } ); FrontmatterDisplay.displayName = "FrontmatterDisplay"; // Re-export parser for use elsewhere export { type ParsedFrontmatter };