import { slugify } from './plans'; export { slugify } from './plans'; export type TocEntry = { id: string; text: string; level: number }; export type ContentBlock = { type: 'heading' | 'paragraph' | 'code' | 'list' | 'quote' | 'hr'; level?: number; id?: string; text?: string; items?: string[]; ordered?: boolean; lang?: string; code?: string; }; /** Anchor id for a heading — a URL-safe slug. */ export function headingId(text: string): string { return slugify(text); } /** Rough reading time in minutes (≈200 words/min), ignoring fenced code. */ export function readingMinutes(body: string): number { const words = body .replace(/```[\s\S]*?```/g, ' ') .split(/\s+/) .filter(Boolean).length; return Math.max(1, Math.round(words / 200)); } export function isPublished(item: { status: string }): boolean { return item.status === 'published'; } export function filterPublished(items: T[]): T[] { return items.filter(isPublished); } function escapeHtml(value: string): string { return value.replace(/&/g, '&').replace(//g, '>'); } /** Allow only safe link targets; anything else becomes a no-op anchor. */ function sanitizeUrl(url: string): string { const trimmed = url.trim(); if (/^(https?:|mailto:|\/|#)/i.test(trimmed)) return trimmed.replace(/"/g, '%22'); return '#'; } /** * Render a single markdown line's inline spans to (escaped) HTML: code, links, bold and italic. * Input is HTML-escaped first, so the result is safe to bind with `v-html`. */ export function renderInline(text: string): string { let out = escapeHtml(text); out = out.replace(/`([^`]+)`/g, '$1'); out = out.replace(/\*\*([^*]+)\*\*/g, '$1'); out = out.replace(/(^|[^*])\*([^*]+)\*/g, '$1$2'); out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, label: string, href: string) => `${label}`); return out; } const KEYWORDS = 'const|let|var|function|return|if|else|for|while|do|switch|case|break|continue|new|class|extends|interface|type|enum|import|from|export|default|await|async|try|catch|finally|throw|typeof|instanceof|in|of|as|is|null|undefined|true|false|this|super|void|yield|def|end|elif|then|fi|esac|echo|local|print|lambda|pass|raise|with|not|and|or|select|where|insert|update|delete|join|group|order|by|limit|pub|fn|impl|match|use|mod|struct|trait|package|func|go|defer'; const TOKEN_RE = new RegExp( `(\\/\\/[^\\n]*|\\/\\*[\\s\\S]*?\\*\\/)|("(?:\\\\.|[^"\\\\])*"|'(?:\\\\.|[^'\\\\])*'|\`(?:\\\\.|[^\`\\\\])*\`)|(\\b\\d+(?:\\.\\d+)?\\b)|(\\b(?:${KEYWORDS})\\b)`, 'g', ); /** Tiny language-agnostic highlighter over escaped code — comments, strings, numbers, keywords. */ export function highlightCode(code: string, _lang = ''): string { const escaped = escapeHtml(code); return escaped.replace(TOKEN_RE, (match, comment, str, num, keyword) => { if (comment) return `${match}`; if (str) return `${match}`; if (num) return `${match}`; if (keyword) return `${match}`; return match; }); } function uniqueHeadingId(used: Map, text: string): string { const base = headingId(text) || 'section'; const seen = used.get(base) ?? 0; used.set(base, seen + 1); return seen === 0 ? base : `${base}-${seen + 1}`; } const FENCE_RE = /^```(\w*)\s*$/; const HEADING_RE = /^(#{1,6})\s+(.*)$/; const HR_RE = /^(-{3,}|\*{3,}|_{3,})\s*$/; const QUOTE_RE = /^>\s?/; const UL_RE = /^[-*+]\s+/; const OL_RE = /^\d+\.\s+/; function startsBlock(line: string): boolean { return ( FENCE_RE.test(line) || HEADING_RE.test(line) || HR_RE.test(line) || QUOTE_RE.test(line) || UL_RE.test(line) || OL_RE.test(line) ); } /** * Parse a markdown-lite body into renderable blocks. Supports fenced code (with an optional * language), ATX headings, ordered/unordered lists, blockquotes, horizontal rules and paragraphs. * Deliberately dependency-free — the content template ships no markdown package. */ export function renderBlocks(body: string): ContentBlock[] { const lines = body.replace(/\r\n/g, '\n').split('\n'); const blocks: ContentBlock[] = []; const used = new Map(); let i = 0; while (i < lines.length) { const line = lines[i] ?? ''; if (/^\s*$/.test(line)) { i++; continue; } const fence = line.match(FENCE_RE); if (fence) { const lang = fence[1] ?? ''; i++; const buffer: string[] = []; while (i < lines.length && !/^```\s*$/.test(lines[i] ?? '')) { buffer.push(lines[i] ?? ''); i++; } if (i < lines.length) i++; blocks.push({ type: 'code', lang, code: buffer.join('\n') }); continue; } const heading = line.match(HEADING_RE); if (heading) { const text = (heading[2] ?? '').trim(); blocks.push({ type: 'heading', level: (heading[1] ?? '').length, text, id: uniqueHeadingId(used, text) }); i++; continue; } if (HR_RE.test(line)) { blocks.push({ type: 'hr' }); i++; continue; } if (QUOTE_RE.test(line)) { const parts: string[] = []; while (i < lines.length && QUOTE_RE.test(lines[i] ?? '')) { parts.push((lines[i] ?? '').replace(QUOTE_RE, '')); i++; } blocks.push({ type: 'quote', text: parts.join(' ').trim() }); continue; } if (UL_RE.test(line) || OL_RE.test(line)) { const ordered = OL_RE.test(line); const pattern = ordered ? OL_RE : UL_RE; const items: string[] = []; while (i < lines.length && pattern.test(lines[i] ?? '')) { items.push((lines[i] ?? '').replace(pattern, '').trim()); i++; } blocks.push({ type: 'list', ordered, items }); continue; } const buffer: string[] = []; while (i < lines.length) { const current = lines[i] ?? ''; if (/^\s*$/.test(current) || startsBlock(current)) break; buffer.push(current.trim()); i++; } blocks.push({ type: 'paragraph', text: buffer.join(' ') }); } return blocks; } /** Table-of-contents entries (h2–h4) parsed from a body, reusing `renderBlocks` anchor ids. */ export function extractHeadings(body: string): TocEntry[] { return renderBlocks(body) .filter((b) => b.type === 'heading' && (b.level ?? 0) >= 2 && (b.level ?? 0) <= 4 && !!b.id) .map((b) => ({ id: b.id as string, text: b.text ?? '', level: b.level ?? 2 })); }