// md.jsx — рендер markdown материалов базы в React-элементы (без innerHTML).
// Вырос из мини-рендера wiki.jsx ЦБ LMS: там хватало заголовков, списков и ссылок,
// материалы базы сообщества — это ещё таблицы, код, цитаты и разделители.
//
// Блоки: ## / ### / ####, абзацы, списки - и 1. (с вложенностью по отступу),
// таблицы |…|, ```код```, > цитаты, ---.
// Строчные: **жирный**, *курсив*, `код`, [текст](url), [[slug]] / [[slug|текст]],
// [текст](slug) — ссылка на другой материал базы по slug.
// Ссылки наружу — только http(s)/mailto, всё прочее выводится текстом.
const MD_SLUG = /^[a-z0-9][a-z0-9-]*$/;
function mdInline(text, ctx, keyBase) {
const parts = [];
const re = /`([^`]+)`|\[\[([a-z0-9-]+)(?:\|([^\]]+))?\]\]|\[([^\]]+)\]\(([^)\s]+)\)|\*\*([^*]+)\*\*|\*([^*\s][^*]*?)\*/g;
let last = 0, m, i = 0;
const k = () => `${keyBase}-${i++}`;
const internal = (slug, label) => {
const known = ctx.slugs && ctx.slugs.has(slug);
if (!known) return label; // материал ещё не написан — просто текст
return { e.preventDefault(); ctx.open(slug); }}>{label};
};
while ((m = re.exec(text))) {
if (m.index > last) parts.push(text.slice(last, m.index));
if (m[1] !== undefined) parts.push({m[1]});
else if (m[2]) parts.push(internal(m[2], m[3] || m[2]));
else if (m[4]) {
const href = m[5];
if (/^(https?:|mailto:)/i.test(href)) {
parts.push({mdInline(m[4], ctx, k())});
} else if (MD_SLUG.test(href.replace(/\.md$/, '').split('/').pop())) {
parts.push(internal(href.replace(/\.md$/, '').split('/').pop(), m[4]));
} else parts.push(m[4]);
}
else if (m[6]) parts.push({mdInline(m[6], ctx, k())});
else if (m[7]) parts.push({m[7]});
last = m.index + m[0].length;
}
if (last < text.length) parts.push(text.slice(last));
return parts;
}
const splitRow = (line) => line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim());
function mdRender(md, ctx = {}) {
const lines = String(md || '').replace(/\r/g, '').split('\n');
const out = [];
let k = 0, i = 0;
const key = () => 'b' + (k++);
while (i < lines.length) {
const raw = lines[i];
const line = raw.trim();
if (!line) { i++; continue; }
// ```код```
if (line.startsWith('```')) {
const buf = []; i++;
while (i < lines.length && !lines[i].trim().startsWith('```')) { buf.push(lines[i]); i++; }
i++;
out.push(
{buf.join('\n')});
continue;
}
// заголовки (# → h2: h1 страницы — заголовок материала)
const h = line.match(/^(#{1,4})\s+(.*)$/);
if (h) {
const lvl = Math.min(4, Math.max(2, h[1].length));
out.push(React.createElement('h' + lvl, { key: key() }, mdInline(h[2], ctx, 'h' + k)));
i++; continue;
}
if (/^(-{3,}|\*{3,}|_{3,})$/.test(line)) { out.push(| {mdInline(c, ctx, kb + 'h' + j)} | )}
|---|
| {mdInline(r[j] || '', ctx, kb + ri + '-' + j)} | )}
{mdRender(buf.join('\n'), ctx)}); continue; } // списки (вложенность по отступу — сдвигом пункта) if (/^([-*+]|\d+[.)])\s+/.test(line)) { const ordered = /^\d/.test(line); const items = []; while (i < lines.length) { const l = lines[i]; const mm = l.match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/); if (mm) { items.push({ depth: Math.min(3, Math.floor(mm[1].replace(/\t/g, ' ').length / 2)), text: mm[3] }); i++; continue; } // продолжение пункта на следующей строке с отступом if (l.trim() && /^\s{2,}/.test(l) && items.length) { items[items.length - 1].text += ' ' + l.trim(); i++; continue; } break; } const kb = 'l' + k; out.push(React.createElement(ordered ? 'ol' : 'ul', { key: key() }, items.map((it, j) =>
{mdInline(buf.join(' '), ctx, 'p' + k)}
); } return out; } window.mdRender = mdRender;