#!/usr/bin/env node
/*
* Генератор сайта «Ремонт квартиры».
* Читает markdown-заметки из родительской папки vault и собирает статический сайт
* в OUT. Дизайн — из assets/. Контент (рендеры, карточки мебели, варианты) берётся
* из самих заметок, поэтому правка заметки → обновление сайта после пересборки.
*/
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 ');
return t;
}
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(/\]\(([^)]+)\)/); // [text](url)
if (md) return md[1].trim();
const bare = cell.match(/https?:\/\/\S+/);
return bare ? bare[0].trim() : '';
}
// ─── парсер 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) {
// split по неэкранированному «|», затем восстанавливаем «\|» → «|» (для ![[img|width]] внутри ячеек)
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 iName = H.findIndex((h, idx) => idx !== iNo && idx !== iPrice && idx !== iLink && idx !== iPhoto && h);
const cards = tbl.rows.map((r, idx) => {
const no = iNo >= 0 && r[iNo] ? r[iNo] : String(idx + 1);
const name = iName >= 0 ? r[iName] : '';
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
? `
`
: '';
const price = priceNum
? `${esc(priceNum)}${unit ? ` ${unit} ` : ''} ` : '';
const link = url
? `Открыть на ${esc(domainLabel(url))} `
: '';
return `${media}
Вариант ${esc(no)}
${inline(name)}
${price}${link}
`;
}).join('\n');
return `${cards}
`;
}
function tableCell(c) {
const w = parseWiki(c);
if (w) return ` `;
if (/^https?:\/\/\S+$/.test(c)) return `Открыть `;
return inline(c);
}
function renderTable(tbl) {
const head = tbl.headers.map((h) => `${inline(h)} `).join('');
const body = tbl.rows.map((r) =>
`${r.map((c) => `${tableCell(c)} `).join('')} `
).join('\n');
return ``;
}
function renderImages(imgs) {
if (imgs.length === 1) {
return ``;
}
const cls = imgs.length === 2 ? 'renders--duo' : 'renders--trio';
const cells = imgs.map((im) => ``).join('');
return `${cells}
`;
}
function renderVariants(items) {
const li = items.map((it) => {
const parts = it.split(/\s+[—–-]\s+/);
const b = parts.shift();
const small = parts.join(' — ');
return `${inline(b)} ${small ? `${inline(small)} ` : ''} `;
}).join('\n');
return `${li} `;
}
// картинка сразу перед списком → блок «отделки» (image | варианты)
function renderFinish(img, items) {
const li = items.map((it) => {
const parts = it.split(/\s+[—–-]\s+/);
const b = parts.shift();
const small = parts.join(' — ');
return `${inline(b)} ${small ? `${inline(small)} ` : ''} `;
}).join('\n');
return ``;
}
function subhead(text, count) {
const c = count != null
? `${count} ${plural(count, 'вариант', 'варианта', 'вариантов')} ` : '';
return `
${inline(text)} ${c}`;
}
// ─── сборка секции ────────────────────────────────────────
function renderSection(sec) {
const md = fs.readFileSync(path.join(ROOT, sec.file), 'utf8');
let blocks = parseBlocks(md);
const titleBlock = blocks.find((b) => b.t === 'h' && b.lvl === 1);
const title = sec.title || (titleBlock ? titleBlock.text : sec.nav);
// лид — первый абзац
const leadBlock = blocks.find((b) => b.t === 'p');
const lead = leadBlock ? leadBlock.text : '';
// тело: всё кроме заголовка h1 и первого абзаца
let body = blocks.filter((b) => b !== titleBlock && b !== leadBlock);
const out = [];
for (let i = 0; i < body.length; i++) {
const b = body[i];
const next = body[i + 1];
if (b.t === 'h') {
let count = null;
if (next) {
if (next.t === 'table' && tableIsCards(next)) count = next.rows.length;
else if (next.t === 'list') count = next.items.length;
else if (next.t === 'images' && body[i + 2] && body[i + 2].t === 'list') count = body[i + 2].items.length;
}
out.push(subhead(b.text, count));
} else if (b.t === 'images') {
if (next && next.t === 'list' && b.imgs.length === 1) {
out.push(renderFinish(b.imgs[0], next.items));
i++; // список поглощён
} else {
out.push(renderImages(b.imgs));
}
} else if (b.t === 'table') {
out.push(tableIsCards(b) ? renderCards(b) : renderTable(b));
} else if (b.t === 'list') {
out.push(renderVariants(b.items));
} else if (b.t === 'quote') {
out.push(`${inline(b.text)}
`);
} else if (b.t === 'p') {
out.push(`${inline(b.text)}
`);
}
}
const sw = (sec.swatches || []).map((s) =>
` ${esc(s.name)} `).join('');
return `
${esc(sec.eyebrow || '')}
${esc(title)}
${lead ? `
${inline(lead)}
` : ''}
${sw ? `
${sw}
` : ''}
${esc(sec.index || '')}
${out.join('\n ')}
`;
}
// ─── секция планировки (таблица комнат из заметки) ───────
function renderPlan(plan) {
const md = fs.readFileSync(path.join(ROOT, plan.file), 'utf8');
const blocks = parseBlocks(md);
const tbl = blocks.find((b) => b.t === 'table');
let rooms = '';
if (tbl) {
const H = tbl.headers;
const iNo = colIndex(H, /^№|^no$|^#$/i);
const iArea = colIndex(H, /площад|м²|area/i);
const iName = H.findIndex((h, idx) => idx !== iNo && idx !== iArea && h);
rooms = tbl.rows.map((r) => {
const no = r[iNo] || '';
const name = r[iName] || '';
const area = r[iArea] || '';
const m = (plan.meta && plan.meta[no]) || {};
const nameHtml = `${esc(name)}${m.sub ? `${esc(m.sub)} ` : ''} `;
const nameCell = m.link ? `${nameHtml} ` : nameHtml;
const tag = m.tag === 'done' ? 'концепт готов '
: m.tag === 'wip' ? 'в работе ' : '';
return `${esc(no)} ${nameCell}${tag}${esc(area)} `;
}).join('\n');
}
return `
${esc(plan.eyebrow)}
${esc(plan.nav)}
${esc(plan.lead)}
${esc(plan.index)}
`;
}
// ─── страница целиком ─────────────────────────────────────
function buildHtml() {
const h = cfg.hero;
const nav = [cfg.plan, ...cfg.sections].map((s) =>
`${esc(s.nav)} ${esc(s.navShort || s.nav)} `).join('\n ');
const materials = h.materials.map((m) =>
` ${esc(m.name)} `).join('\n ');
const stats = h.stats.map((s) =>
`${esc(s.num)} ${esc(s.lbl)}
`).join('\n ');
const sections = cfg.sections.map(renderSection).join('\n\n');
return `
${esc(cfg.title)}
${esc(h.eyebrow)}
${h.title}
${esc(h.lead)}
${materials}
${stats}
Планировка
${renderPlan(cfg.plan)}
${sections}
×
`;
}
// ─── оптимизация картинок ─────────────────────────────────
async function optimizeImages() {
fs.mkdirSync(OUT_IMG, { recursive: true });
for (const name of usedImages) {
const src = path.join(IMG_SRC, name);
const dst = path.join(OUT_IMG, name.replace(/\.[a-z0-9]+$/i, '') + '.webp');
if (!fs.existsSync(src)) { console.warn('⚠ нет картинки:', name); continue; }
await sharp(src).resize({ width: 1600, withoutEnlargement: true }).webp({ quality: 82 }).toFile(dst);
}
}
// ─── main ─────────────────────────────────────────────────
(async () => {
fs.rmSync(OUT, { recursive: true, force: true });
fs.mkdirSync(path.join(OUT, 'css'), { recursive: true });
fs.mkdirSync(path.join(OUT, 'js'), { recursive: true });
const html = buildHtml(); // наполняет usedImages
fs.writeFileSync(path.join(OUT, 'index.html'), html);
fs.copyFileSync(path.join(HERE, 'assets/style.css'), path.join(OUT, 'css/style.css'));
fs.copyFileSync(path.join(HERE, 'assets/app.js'), path.join(OUT, 'js/app.js'));
await optimizeImages();
console.log(`✅ Сайт собран: ${OUT}`);
console.log(` картинок: ${usedImages.size}, секций: ${cfg.sections.length}`);
})().catch((e) => { console.error('❌', e); process.exit(1); });