'use client' import * as React from 'react' import { cn } from '../../lib/utils' const ENTITIES: Record = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", ' ': ' ', } /** * Flatten markdown / HTML / whitespace-noisy text into a single-line preview, * clamped to `maxChars` on a word boundary. Board cards get a readable content * snippet without pulling in a full markdown renderer (startsim-768w.17.3): the * pipeline writes long article bodies to `data.content`, and a raw slice of that * shows heading hashes, link syntax and code fences. */ export function toPlainSnippet( input: string | null | undefined, maxChars = 240, ): string { if (!input) return '' let s = String(input) s = s.replace(/```[\s\S]*?```/g, ' ') // fenced code blocks s = s.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') // images -> alt text s = s.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // links -> link text s = s.replace(/<[^>]+>/g, ' ') // html tags s = s.replace(/^[ \t]*[#>\-*+]+[ \t]+/gm, ' ') // heading / list / quote markers s = s.replace(/[*_`~]+/g, '') // inline emphasis / code marks s = s.replace(/&[a-z#0-9]+;/gi, (m) => ENTITIES[m.toLowerCase()] ?? ' ') s = s.replace(/\s+/g, ' ').trim() if (s.length <= maxChars) return s const cut = s.slice(0, maxChars) const lastSpace = cut.lastIndexOf(' ') // cut back to the last whole word when that boundary is in the back half, // otherwise hard-cut (e.g. a single very long token has no usable boundary) const body = lastSpace > maxChars * 0.5 ? cut.slice(0, lastSpace) : cut return body.trimEnd() + '…' } const LINE_CLAMP: Record = { 2: 'line-clamp-2', 3: 'line-clamp-3', 4: 'line-clamp-4', 5: 'line-clamp-5', 6: 'line-clamp-6', } export interface BoardCardMeta { label: string value: React.ReactNode } export interface BoardCardProps { /** Card heading (record name / title). */ title: React.ReactNode /** Raw content (markdown / HTML / plain); shown as a clamped plain-text preview. */ snippet?: string | null /** Max plain-text characters for the snippet (default 240). */ snippetMaxChars?: number /** Tailwind line-clamp count for the snippet (default 3). */ snippetLines?: 2 | 3 | 4 | 5 | 6 /** Source / host label or link, shown under the snippet. */ source?: React.ReactNode /** Compact key/value chips for short declared attributes. */ meta?: BoardCardMeta[] /** Opens the record detail; wraps the title as a button when provided. */ onOpen?: () => void /** * Footer slot — e.g. a status `