/** * 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 `${alt}`; }); 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("
"); i++; continue; } // заголовок const h = line.match(/^(#{1,6})\s+(.*)$/); if (h) { flushPara(); const level = h[1].length; html.push(`${inline(h[2].trim())}`); i++; continue; } // цитата — подряд идущие строки > if (/^\s*>\s?/.test(line)) { flushPara(); const buf = []; while (i < lines.length && /^\s*>\s?/.test(lines[i])) { buf.push(lines[i].replace(/^\s*>\s?/, "")); i++; } 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) => `
  • ${inline(it)}
  • `).join("")}`); continue; } // обычный абзац para.push(line.trim()); i++; } flushPara(); return html.join("\n"); }