import { slugify } from './utils'; /** * Pure content helpers — no DB, no `server-only`, so they are directly unit-testable. * The same heading algorithm backs both the table of contents and the rendered body, * which keeps TOC anchors in sync with the headings they point at. */ export type Heading = { id: string; text: string; level: number }; const HEADING_RE = /^(#{2,3})\s+(.+?)\s*#*\s*$/; /** Words-per-minute used for the reading-time estimate. */ const WPM = 200; export function readingMinutes(body: string): number { const words = body.trim().split(/\s+/).filter(Boolean).length; return Math.max(1, Math.round(words / WPM)); } export function headingId(text: string): string { return slugify(text) || 'section'; } /** Extract h2/h3 headings for a table of contents; ids are de-duplicated in document order. */ export function extractHeadings(body: string): Heading[] { const out: Heading[] = []; const seen = new Map(); for (const raw of body.split('\n')) { const m = HEADING_RE.exec(raw.trim()); if (!m) continue; const level = m[1].length; const text = m[2].trim(); const base = headingId(text); const n = seen.get(base) ?? 0; seen.set(base, n + 1); out.push({ id: n === 0 ? base : `${base}-${n}`, text, level }); } return out; } export function isPublished(status: unknown): boolean { return status === 'published'; } /** Keep only published items (a draft must never leak onto a public list). */ export function filterPublished(items: T[]): T[] { return items.filter((item) => isPublished(item.status)); } /** Derive a plain-text excerpt from a markdown body when none is supplied. */ export function excerptFromBody(body: string, max = 180): string { const text = body .replace(/```[\s\S]*?```/g, ' ') .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ') .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') .replace(/[#>*_`]/g, '') .replace(/\s+/g, ' ') .trim(); return text.length > max ? `${text.slice(0, max).trimEnd()}…` : text; }