Сайт: статьи из 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:
parent
be3182e0c9
commit
aa50d83bf0
36 changed files with 1198 additions and 11 deletions
188
lib/markdown.js
Normal file
188
lib/markdown.js
Normal 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, "&")
|
||||
.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");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue