188 lines
6.1 KiB
JavaScript
188 lines
6.1 KiB
JavaScript
|
|
/**
|
|||
|
|
* 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, ">")
|
|||
|
|
.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 `<img src="${safeUrl(url)}" alt="${alt}" loading="lazy">`;
|
|||
|
|
});
|
|||
|
|
out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => {
|
|||
|
|
const ext = /^https?:/i.test(url) ? ' target="_blank" rel="noopener"' : "";
|
|||
|
|
return `<a href="${safeUrl(url)}"${ext}>${label}</a>`;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
out = out.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|||
|
|
out = out.replace(/(^|[^*])\*([^*]+)\*/g, "$1<em>$2</em>");
|
|||
|
|
|
|||
|
|
out = out.replace(/\u0000(\d+)\u0000/g, (_, i) => `<code>${codes[Number(i)]}</code>`);
|
|||
|
|
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(`<p>${inline(para.join(" "))}</p>`);
|
|||
|
|
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(`<pre><code${cls}>${escapeHtml(buf.join("\n"))}</code></pre>`);
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// пустая строка
|
|||
|
|
if (!line.trim()) {
|
|||
|
|
flushPara();
|
|||
|
|
i++;
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// горизонтальная линия
|
|||
|
|
if (/^\s*(---|\*\*\*|___)\s*$/.test(line)) {
|
|||
|
|
flushPara();
|
|||
|
|
html.push("<hr>");
|
|||
|
|
i++;
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// заголовок
|
|||
|
|
const h = line.match(/^(#{1,6})\s+(.*)$/);
|
|||
|
|
if (h) {
|
|||
|
|
flushPara();
|
|||
|
|
const level = h[1].length;
|
|||
|
|
html.push(`<h${level}>${inline(h[2].trim())}</h${level}>`);
|
|||
|
|
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(`<blockquote>${renderMarkdown(buf.join("\n"))}</blockquote>`);
|
|||
|
|
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) => `<li>${inline(it)}</li>`).join("")}</${tag}>`);
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// обычный абзац
|
|||
|
|
para.push(line.trim());
|
|||
|
|
i++;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
flushPara();
|
|||
|
|
return html.join("\n");
|
|||
|
|
}
|