Сайт: статьи из 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 переработан под публику)
This commit is contained in:
eureka 2026-09-21 21:18:43 +00:00
parent be3182e0c9
commit aa50d83bf0
36 changed files with 1198 additions and 11 deletions

188
lib/markdown.js Normal file
View file

@ -0,0 +1,188 @@
/**
* 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");
}

View file

@ -21,12 +21,12 @@ export function esc(s) {
export const NAV = [
{ key: "home", label: "Главная", href: "/" },
{ key: "gallery", label: "Галерея", href: "/gallery/" },
{ key: "articles", label: "Статьи", href: "/articles/" },
{ key: "workshop", label: "Мастерская", href: "/workshop/" },
{ key: "about", label: "О себе", href: "/#about" },
// Задача 006 добавит сюда: { key: "articles", label: "Статьи", href: "/articles/" }
];
export function page({ title, description = "", body, active = "", mainClass = "" }) {
export function page({ title, description = "", body, active = "", mainClass = "", headExtra = "" }) {
const nav = NAV.map(
(n) =>
` <a href="${n.href}"${
@ -41,7 +41,7 @@ export function page({ title, description = "", body, active = "", mainClass = "
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${esc(title)}</title>
${description ? `<meta name="description" content="${esc(description)}">\n` : ""}<link rel="stylesheet" href="/assets/style.css">
</head>
${headExtra ? `${headExtra}\n` : ""}</head>
<body>
<header class="top">
<a class="brand" href="/">Эврика</a>