62 lines
2 KiB
TypeScript
62 lines
2 KiB
TypeScript
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<string, number>();
|
|
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<T extends { status: string }>(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;
|
|
}
|