196 lines
6.5 KiB
TypeScript
196 lines
6.5 KiB
TypeScript
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<T extends { status: string }>(items: T[]): T[] {
|
||
return items.filter(isPublished);
|
||
}
|
||
|
||
function escapeHtml(value: string): string {
|
||
return value.replace(/&/g, '&').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, '<code>$1</code>');
|
||
out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||
out = out.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>');
|
||
out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, label: string, href: string) => `<a href="${sanitizeUrl(href)}">${label}</a>`);
|
||
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 `<span class="tok-com">${match}</span>`;
|
||
if (str) return `<span class="tok-str">${match}</span>`;
|
||
if (num) return `<span class="tok-num">${match}</span>`;
|
||
if (keyword) return `<span class="tok-kw">${match}</span>`;
|
||
return match;
|
||
});
|
||
}
|
||
|
||
function uniqueHeadingId(used: Map<string, number>, 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<string, number>();
|
||
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 }));
|
||
}
|