templates2.Content.Next/lib/content.ts
w4c-templates 3caefb8833
Some checks are pending
CI / quality (push) Waiting to run
chore(template): publish Content.Next
2026-10-11 23:29:57 +03:00

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;
}