templates2.Content.Nuxt/shared/content.ts
w4c-templates 940313f8c5
Some checks failed
CI / quality (push) Has been cancelled
chore(template): publish Content.Nuxt
2026-10-11 23:26:40 +03:00

196 lines
6.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/** 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 }));
}