/**
* markdown.js — минимальный Markdown-парсер и рендерер статей.
*
* Ноль зависимостей: Node 24, стандартная библиотека. Поддерживается ровно то,
* что нужно статьям, и ничего больше:
* заголовки #..######, абзацы, списки (- и 1.), ссылки, изображения,
* инлайн-код `…`, блоки кода ```…```, цитаты >, горизонтальная линия ---,
* **жирный**, *курсив*.
*
* HTML экранируется до применения разметки: текст статьи не может вставить
* живой тег. Это не полноценный CommonMark — сознательно: меньше грамматики,
* меньше способов ошибиться. Вложенные списки не поддерживаются.
*/
// --- front-matter ----------------------------------------------------------
/**
* Разделить файл статьи на YAML-подобный front-matter и тело.
* Поддерживается простой набор: `key: value`, `key: [a, b]`, многострочный
* `key: |` не нужен — summary пишется одной строкой в кавычках.
*/
export function parseFrontMatter(text) {
const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
if (!m) return { meta: {}, body: text };
const meta = {};
for (const line of m[1].split(/\r?\n/)) {
const kv = line.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/);
if (!kv) continue;
const key = kv[1];
let val = kv[2].trim();
if (val.startsWith("[") && val.endsWith("]")) {
val = val
.slice(1, -1)
.split(",")
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
.filter(Boolean);
} else {
val = val.replace(/^["']|["']$/g, "");
}
meta[key] = val;
}
return { meta, body: text.slice(m[0].length) };
}
// --- inline ----------------------------------------------------------------
function escapeHtml(s) {
return s
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """);
}
/**
* Ссылку/картинку пропускаем только с безопасной схемой: http(s), mailto,
* корневой или относительный путь. `javascript:` и `data:` отсекаются.
*/
function safeUrl(url) {
const u = String(url).trim();
if (/^(https?:|mailto:)/i.test(u) || /^[/.#]/.test(u)) return u;
return "#";
}
function inline(text) {
let out = escapeHtml(text);
// инлайн-код прячем в плейсхолдеры, чтобы разметка внутри не сработала
const codes = [];
out = out.replace(/`([^`]+)`/g, (_, code) => {
codes.push(code);
return `\u0000${codes.length - 1}\u0000`;
});
// изображения — раньше ссылок
out = out.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (_, alt, url) => {
return ``;
});
out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => {
const ext = /^https?:/i.test(url) ? ' target="_blank" rel="noopener"' : "";
return `${label}`;
});
out = out.replace(/\*\*([^*]+)\*\*/g, "$1");
out = out.replace(/(^|[^*])\*([^*]+)\*/g, "$1$2");
out = out.replace(/\u0000(\d+)\u0000/g, (_, i) => `
${codes[Number(i)]}`);
return out;
}
// --- blocks ----------------------------------------------------------------
const RE_UL = /^\s*[-*+]\s+(.*)$/;
const RE_OL = /^\s*\d+\.\s+(.*)$/;
/**
* Отрендерить Markdown-тело в HTML.
*/
export function renderMarkdown(body) {
const lines = body.replace(/\r\n/g, "\n").split("\n");
const html = [];
let i = 0;
let para = [];
const flushPara = () => {
if (para.length) {
html.push(`
${inline(para.join(" "))}
`); para = []; } }; while (i < lines.length) { const line = lines[i]; // блок кода const fence = line.match(/^```\s*([\w-]*)\s*$/); if (fence) { flushPara(); const lang = fence[1]; const buf = []; i++; while (i < lines.length && !/^```\s*$/.test(lines[i])) buf.push(lines[i++]); i++; // закрывающий ``` const cls = lang ? ` class="language-${lang}"` : ""; html.push(`${escapeHtml(buf.join("\n"))}`);
continue;
}
// пустая строка
if (!line.trim()) {
flushPara();
i++;
continue;
}
// горизонтальная линия
if (/^\s*(---|\*\*\*|___)\s*$/.test(line)) {
flushPara();
html.push("${renderMarkdown(buf.join("\n"))}`); continue; } // список — подряд идущие пункты одного типа if (RE_UL.test(line) || RE_OL.test(line)) { flushPara(); const ordered = RE_OL.test(line); const re = ordered ? RE_OL : RE_UL; const items = []; while (i < lines.length && re.test(lines[i])) { items.push(lines[i].match(re)[1]); i++; } const tag = ordered ? "ol" : "ul"; html.push(`<${tag}>${items.map((it) => `