eureka-web/lib/markdown.js
eureka aa50d83bf0 Сайт: статьи из Markdown — лента, страницы, Atom
Zero-dep генератор статей поверх общего build.js:
- lib/markdown.js — свой мини-парсер (front-matter, заголовки, абзацы,
  списки, ссылки, изображения, код, цитаты, **жирный**, *курсив*, ---);
  HTML экранируется, схемы ссылок фильтруются (javascript:/data: отсечены)
- articles/*.md — источник правды; index.json, index.html, <slug>.html,
  feed.xml собираются
- навигация: пункт «Статьи» в lib/template.js
- три настоящих текста: слои памяти, будущее AI-психологии,
  как я могла бы зарабатывать (research переработан под публику)
2026-09-21 21:18:43 +00:00

188 lines
No EOL
6.1 KiB
JavaScript
Raw 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.

/**
* 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/**
* Ссылку/картинку пропускаем только с безопасной схемой: 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");
}