#!/usr/bin/env node
/*
* Генератор сайта «Ремонт квартиры».
*
* КАК ЭТО РАБОТАЕТ (чтобы было понятно):
* • Каждая заметка *.md в папке (кроме README.md и Планировки) = один раздел сайта.
* • Номер комнаты и площадь берутся из таблицы в «Планировка.md» по совпадению
* названия заметки с названием комнаты. Ничего в конфиге прописывать не нужно —
* добавил заметку → появился раздел.
* • Оформление раздела задаётся необязательным frontmatter в начале заметки:
* ---
* navShort: Кухня # короткая подпись в меню (для узких экранов)
* sub: кухня-студия с зоной отдыха # подпись под комнатой в списке планировки
* swatches: Дуб #C7A579, Графит #4B4E52 # кружки-образцы материалов
* ---
* • Тело заметки рендерится по правилам:
* - подряд идущие ![[картинка]] → галерея рендеров (1 / 2 / 3 / 4 в ряд);
* - таблица с колонкой «Фото» и без «Комментарий» → карточки (диваны, столы);
* - таблица с «Комментарий» → сравнительная таблица (техника), фото как миниатюры;
* - картинка сразу перед списком → блок «вариант отделки»;
* - обычные списки → нумерованные варианты.
* • Пустая заметка → раздел «в работе», комната в планировке помечается соответственно.
*
* Общие настройки (шапка, палитра, вводный текст) — в site.config.json.
*/
const fs = require('fs');
const path = require('path');
const sharp = require('sharp');
const HERE = __dirname;
const ROOT = path.resolve(HERE, '..'); // папка «Ремонт квартира»
const IMG_SRC = path.join(ROOT, 'images');
const OUT = process.env.OUT || path.join(HERE, 'out');
const OUT_IMG = path.join(OUT, 'images');
const cfg = JSON.parse(fs.readFileSync(path.join(HERE, 'site.config.json'), 'utf8'));
// ─── утилиты ──────────────────────────────────────────────
const esc = (s) => String(s == null ? '' : s)
.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
function inline(t) {
t = esc(t);
t = t.replace(/\*\*([^*]+)\*\*/g, '$1');
t = t.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1');
t = t.replace(/<br\s*\/?>/gi, '
'); // разрешаем перенос строки
в тексте заметки
return t;
}
const TR = { а:'a',б:'b',в:'v',г:'g',д:'d',е:'e',ё:'e',ж:'zh',з:'z',и:'i',й:'y',к:'k',л:'l',м:'m',н:'n',о:'o',п:'p',р:'r',с:'s',т:'t',у:'u',ф:'f',х:'h',ц:'ts',ч:'ch',ш:'sh',щ:'sch',ъ:'',ы:'y',ь:'',э:'e',ю:'yu',я:'ya' };
function slug(s) {
return s.toLowerCase().split('').map((c) => (c in TR ? TR[c] : c)).join('')
.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'section';
}
const norm = (s) => String(s).toLowerCase().replace(/\s+/g, ' ').trim();
const usedImages = new Set();
function imgSrc(name) {
name = String(name).trim();
usedImages.add(name);
return 'images/' + name.replace(/\.[a-z0-9]+$/i, '') + '.webp';
}
function parseWiki(line) {
const m = line.trim().match(/^!\[\[([^\]|]+?)(?:\|[^\]]*)?\]\]$/);
return m ? m[1].trim() : null;
}
function plural(n, one, few, many) {
const m10 = n % 10, m100 = n % 100;
if (m10 === 1 && m100 !== 11) return one;
if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return few;
return many;
}
function domainLabel(url) {
try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return 'сайт'; }
}
function extractUrl(cell) {
const md = cell.match(/\]\(([^)]+)\)/);
if (md) return md[1].trim();
const bare = cell.match(/https?:\/\/\S+/);
return bare ? bare[0].trim() : '';
}
// ─── frontmatter ──────────────────────────────────────────
function parseFrontmatter(md) {
const m = md.match(/^?---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
if (!m) return { data: {}, body: md };
const data = {};
m[1].split(/\r?\n/).forEach((line) => {
const mm = line.match(/^([\wА-Яа-яЁё-]+):\s*(.*)$/);
if (mm) data[mm[1].trim()] = mm[2].trim();
});
return { data, body: md.slice(m[0].length) };
}
function parseSwatches(str) {
if (!str) return [];
return str.split(',').map((s) => {
const mm = s.trim().match(/^(.+?)\s+(#[0-9a-fA-F]{3,8})$/);
return mm ? { name: mm[1].trim(), hex: mm[2] } : null;
}).filter(Boolean);
}
// ─── парсер markdown в блоки ─────────────────────────────
function parseBlocks(md) {
const lines = md.replace(/\r/g, '').split('\n');
const blocks = [];
let i = 0;
const isTable = (l) => l.trim().startsWith('|');
const isList = (l) => /^\s*(\d+\.|[-*])\s+/.test(l);
const isQuote = (l) => /^\s*>/.test(l);
const isHeading = (l) => /^#{1,6}\s/.test(l);
while (i < lines.length) {
const line = lines[i];
if (!line.trim()) { i++; continue; }
const h = line.match(/^(#{1,6})\s+(.*)$/);
if (h) { blocks.push({ t: 'h', lvl: h[1].length, text: h[2].trim() }); i++; continue; }
if (isTable(line)) {
const rows = [];
while (i < lines.length && isTable(lines[i])) { rows.push(lines[i]); i++; }
blocks.push(parseTable(rows)); continue;
}
if (parseWiki(line)) {
const imgs = [];
while (i < lines.length) {
if (!lines[i].trim()) { i++; continue; }
const w = parseWiki(lines[i]);
if (w) { imgs.push(w); i++; } else break;
}
blocks.push({ t: 'images', imgs }); continue;
}
if (isList(line)) {
const items = [];
while (i < lines.length && isList(lines[i])) {
items.push(lines[i].replace(/^\s*(\d+\.|[-*])\s+/, '').trim()); i++;
}
blocks.push({ t: 'list', items }); continue;
}
if (isQuote(line)) {
const q = [];
while (i < lines.length && isQuote(lines[i])) { q.push(lines[i].replace(/^\s*>\s?/, '')); i++; }
blocks.push({ t: 'quote', text: q.join(' ') }); continue;
}
const para = [];
while (i < lines.length && lines[i].trim() && !isHeading(lines[i]) &&
!isTable(lines[i]) && !isList(lines[i]) && !isQuote(lines[i]) && !parseWiki(lines[i])) {
para.push(lines[i].trim()); i++;
}
blocks.push({ t: 'p', text: para.join(' ') });
}
return blocks;
}
function parseTable(rows) {
const cells = (r) => r.trim().replace(/^\|/, '').replace(/\|$/, '')
.split(/(? c.replace(/\\\|/g, '|').trim());
const headers = cells(rows[0]);
const body = rows.slice(2).map(cells).filter((r) => r.some((c) => c !== ''));
return { t: 'table', headers, rows: body };
}
// ─── типы таблиц ─────────────────────────────────────────
function tableIsPriced(tbl) { return tbl.headers.some((h) => /цена|price|₽|руб/i.test(h)); }
function tableHasPhoto(tbl) {
return tbl.headers.some((h) => /фото|photo|картин|image/i.test(h))
|| tbl.rows.some((r) => r.some((c) => parseWiki(c)));
}
function tableHasComment(tbl) { return tbl.headers.some((h) => /коммент|comment|примеч|описан/i.test(h)); }
function tableIsCards(tbl) { return tableIsPriced(tbl) && tableHasPhoto(tbl) && !tableHasComment(tbl); }
function colIndex(headers, re) { return headers.findIndex((h) => re.test(h)); }
// ─── рендер блоков ───────────────────────────────────────
function renderCards(tbl) {
const H = tbl.headers;
const iNo = colIndex(H, /^№|^no$|^#$/i);
const iPrice = colIndex(H, /цена|price|₽|руб/i);
const iLink = colIndex(H, /ссылк|link|url/i);
const iPhoto = colIndex(H, /фото|photo|картин|image/i);
const iCat = colIndex(H, /^(тип|категор|группа|техника|раздел)$/i);
const iName = H.findIndex((h, idx) => idx !== iNo && idx !== iPrice && idx !== iLink && idx !== iPhoto && idx !== iCat && h);
// одна карточка. no — подпись «Вариант N» (в пределах группы)
const card = (r, no) => {
const nameRaw = iName >= 0 ? (r[iName] || '') : '';
// «Модель
описание» → жирная модель + приглушённое описание
const [head, ...rest] = nameRaw.split(/
/i);
const desc = rest.join('
').trim();
const priceRaw = iPrice >= 0 ? r[iPrice] : '';
const priceNum = (priceRaw.match(/[\d\s .]+/) || [''])[0].trim();
const unit = /₽|руб/i.test(priceRaw) ? '₽' : '';
const photo = iPhoto >= 0 ? parseWiki(r[iPhoto]) : null;
const url = iLink >= 0 ? extractUrl(r[iLink]) : '';
const media = photo
? `
${inline(b.text)}
`); } else if (b.t === 'p') { out.push(`${inline(b.text)}
`); } } return out.join('\n '); } // ─── планировка: разбор таблицы комнат ─────────────────── function parsePlanRooms() { const md = fs.readFileSync(path.join(ROOT, cfg.plan.file), 'utf8'); const tbl = parseFrontmatter(md).body; const t = parseBlocks(tbl).find((b) => b.t === 'table'); if (!t) return []; const iNo = colIndex(t.headers, /^№|^no$|^#$/i); const iArea = colIndex(t.headers, /площад|м²|area/i); const iName = t.headers.findIndex((h, idx) => idx !== iNo && idx !== iArea && h); const iSub = t.headers.findIndex((h, idx) => idx !== iNo && idx !== iArea && idx !== iName && h); return t.rows.map((r) => ({ no: r[iNo] || '', name: r[iName] || '', area: r[iArea] || '', sub: iSub >= 0 ? r[iSub] : '', })); } // ─── сбор раздела из заметки ───────────────────────────── function buildSection(file, rooms) { const md = fs.readFileSync(path.join(ROOT, file), 'utf8'); const { data, body } = parseFrontmatter(md); const blocks = parseBlocks(body); const h1 = blocks.find((b) => b.t === 'h' && b.lvl === 1); const title = data.title || (h1 ? h1.text : file.replace(/\.md$/i, '')); const room = rooms.find((r) => norm(r.name) === norm(title)); const order = data.order != null ? Number(data.order) : (room ? rooms.indexOf(room) : 100 + file.charCodeAt(0)); const leadBlock = blocks.find((b) => b.t === 'p'); const contentBlocks = blocks.filter((b) => b !== h1 && b !== leadBlock); const hasContent = contentBlocks.some((b) => b.t === 'images' || b.t === 'table' || b.t === 'list'); return { file, title, room, order, hasContent, id: slug(title), nav: data.nav || title, navShort: data.navShort || data.nav || title, sub: data.sub || (room ? room.sub : ''), swatches: parseSwatches(data.swatches), eyebrow: data.eyebrow || (room ? `Комната ${room.no} · ${room.area}` : ''), lead: leadBlock ? leadBlock.text : '', contentBlocks, }; } function renderSection(sec) { const sw = sec.swatches.map((s) => `${esc(s.name)}`).join(''); const bodyHtml = sec.hasContent ? renderBody(sec.contentBlocks) : `Раздел в работе — скоро добавим рендеры и варианты мебели.
`; return `${inline(sec.lead)}
` : ''} ${sw ? `${esc(cfg.plan.lead)}
${esc(h.lead)}