Files
obsidian/Ремонт квартира/.site/generate.js
Cosmo 526e7ce622
All checks were successful
Deploy Remont Site / deploy (push) Successful in 18s
Ремонт: cache-busting для CSS/JS (?v=хэш)
Ссылки на style.css/app.js получают ?v=<md5 содержимого>. При изменении
стилей/скриптов URL меняется → браузер подтягивает свежую версию сразу,
не дожидаясь истечения 7-дневного кэша (была видна старая CSS после правок).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KjyzuPoH9T5k3oCbKEEazy
2026-07-16 10:13:18 +00:00

526 lines
25 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 crypto = require('crypto');
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'));
// хэш содержимого ассета → ?v=… чтобы браузер сбрасывал кэш при изменении CSS/JS
function assetVer(rel) {
const buf = fs.readFileSync(path.join(HERE, 'assets', rel));
return crypto.createHash('md5').update(buf).digest('hex').slice(0, 8);
}
const CSS_VER = assetVer('style.css');
const JS_VER = assetVer('app.js');
// ─── утилиты ──────────────────────────────────────────────
const esc = (s) => String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
function inline(t) {
t = esc(t);
t = t.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
t = t.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
t = t.replace(/&lt;br\s*\/?&gt;/gi, '<br>'); // разрешаем перенос строки <br> в тексте заметки
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(/(?<!\\)\|/).map((c) => 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] || '') : '';
// «Модель<br>описание» → жирная модель + приглушённое описание
const [head, ...rest] = nameRaw.split(/<br\s*\/?>/i);
const desc = rest.join('<br>').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
? `<div class="opt__media" data-zoom-wrap><img src="${imgSrc(photo)}" alt="${esc(head)}" data-zoom loading="lazy"></div>` : '';
const price = priceNum
? `<span class="opt__price">${esc(priceNum)}${unit ? ` <span>${unit}</span>` : ''}</span>` : '';
const link = url
? `<a class="opt__link" href="${esc(url)}" target="_blank" rel="noopener">Открыть на ${esc(domainLabel(url))} <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 17 17 7M8 7h9v9"/></svg></a>` : '';
return `<article class="opt reveal">${media}<div class="opt__body">
<span class="opt__no">Вариант ${esc(no)}</span>
<span class="opt__name">${inline(head)}</span>
${desc ? `<span class="opt__desc">${inline(desc)}</span>` : ''}
${price}${link}</div></article>`;
};
// без колонки типа — простая сетка карточек
if (iCat < 0) {
const cards = tbl.rows.map((r, idx) => card(r, iNo >= 0 && r[iNo] ? r[iNo] : idx + 1)).join('\n');
return `<div class="options">${cards}</div>`;
}
// с колонкой типа — группируем по типу (пустая ячейка наследует тип сверху)
const groups = [];
let cur = null, lastCat = '';
tbl.rows.forEach((r) => {
const cat = (r[iCat] && r[iCat].trim()) || lastCat;
lastCat = cat;
if (!cur || cur.cat !== cat) { cur = { cat, rows: [] }; groups.push(cur); }
cur.rows.push(r);
});
return groups.map((g) => {
const cards = g.rows.map((r, i) => card(r, i + 1)).join('\n');
return `<div class="opt-group reveal">
<h4 class="opt-group__title">${inline(g.cat)}<span>${g.rows.length} ${plural(g.rows.length, 'вариант', 'варианта', 'вариантов')}</span></h4>
<div class="options">${cards}</div></div>`;
}).join('\n');
}
function tableCell(c) {
const w = parseWiki(c);
if (w) return `<img src="${imgSrc(w)}" alt="" loading="lazy">`;
if (/^https?:\/\/\S+$/.test(c)) return `<a href="${esc(c)}" target="_blank" rel="noopener" class="tlink">Открыть <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 17 17 7M8 7h9v9"/></svg></a>`;
return inline(c);
}
function renderTable(tbl) {
const head = tbl.headers.map((h) => `<th>${inline(h)}</th>`).join('');
const body = tbl.rows.map((r) => `<tr>${r.map((c) => `<td>${tableCell(c)}</td>`).join('')}</tr>`).join('\n');
return `<div class="table-wrap reveal"><table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`;
}
function renderImages(imgs) {
if (imgs.length === 1) {
return `<div class="renders renders--hero reveal"><div class="render" data-zoom-wrap><img src="${imgSrc(imgs[0])}" alt="" data-zoom loading="lazy"></div></div>`;
}
const cls = imgs.length === 2 ? 'renders--duo' : imgs.length === 4 ? 'renders--quad' : 'renders--trio';
const cells = imgs.map((im) => `<div class="render" data-zoom-wrap><img src="${imgSrc(im)}" alt="" data-zoom loading="lazy"></div>`).join('');
return `<div class="renders ${cls} reveal">${cells}</div>`;
}
function variantItems(items) {
return items.map((it) => {
const parts = it.split(/\s+[—–-]\s+/);
const b = parts.shift();
const small = parts.join(' — ');
return `<li><span><b>${inline(b)}</b>${small ? `<small>${inline(small)}</small>` : ''}</span></li>`;
}).join('\n');
}
function renderVariants(items) { return `<ol class="variants reveal">${variantItems(items)}</ol>`; }
function renderFinish(img, items) {
return `<div class="finish reveal">
<div class="finish__img" data-zoom-wrap><img src="${imgSrc(img)}" alt="" data-zoom loading="lazy"></div>
<ol class="variants">${variantItems(items)}</ol></div>`;
}
function subhead(text, count) {
const c = count != null
? `<span class="count">${count} ${plural(count, 'вариант', 'варианта', 'вариантов')}</span>` : '';
return `<div class="subhead reveal"><h3>${inline(text)}</h3>${c}</div>`;
}
// тело раздела (список блоков → html)
function renderBody(body) {
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(`<p class="note reveal">${inline(b.text)}</p>`);
} else if (b.t === 'p') {
out.push(`<p class="prose reveal">${inline(b.text)}</p>`);
}
}
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) => `<span class="swatch"><i style="background:${esc(s.hex)}"></i>${esc(s.name)}</span>`).join('');
const bodyHtml = sec.hasContent
? renderBody(sec.contentBlocks)
: `<p class="section-empty reveal">Раздел в работе — скоро добавим рендеры и варианты мебели.</p>`;
return `<section class="section" id="${esc(sec.id)}">
<div class="wrap">
<div class="section__head reveal">
<div>
<span class="eyebrow">${esc(sec.eyebrow)}</span>
<h2>${esc(sec.title)}</h2>
${sec.lead ? `<p class="section__lead">${inline(sec.lead)}</p>` : ''}
${sw ? `<div class="swatches">${sw}</div>` : ''}
</div>
<div class="section__index" aria-hidden="true">${esc(sec.index)}</div>
</div>
${bodyHtml}
</div>
</section>`;
}
function renderPlan(rooms, sections) {
const byRoom = new Map();
sections.forEach((s) => { if (s.room) byRoom.set(s.room.no, s); });
const items = rooms.map((r) => {
const sec = byRoom.get(r.no);
const nameHtml = `<span class="room-name">${esc(r.name)}${r.sub || (sec && sec.sub) ? `<small>${esc(r.sub || sec.sub)}</small>` : ''}</span>`;
const nameCell = sec ? `<a class="rooms-link" href="#${esc(sec.id)}">${nameHtml}</a>` : nameHtml;
const tag = sec ? (sec.hasContent
? '<span class="tag tag--done">концепт готов</span>'
: '<span class="tag tag--wip">в работе</span>') : '';
return `<li><span class="room-no">${esc(r.no)}</span>${nameCell}${tag}<span class="room-area">${esc(r.area)}</span></li>`;
}).join('\n');
return `<section class="section" id="${esc(cfg.plan.id)}">
<div class="wrap">
<div class="section__head reveal">
<div>
<span class="eyebrow">${esc(cfg.plan.eyebrow)}</span>
<h2>${esc(cfg.plan.nav)}</h2>
<p class="section__lead">${esc(cfg.plan.lead)}</p>
</div>
<div class="section__index" aria-hidden="true">01</div>
</div>
<div class="plan-layout">
<figure class="plan-figure reveal"><img src="${imgSrc(cfg.hero.planImage)}" alt="Схема планировки" data-zoom></figure>
<ul class="rooms reveal">${items}</ul>
</div>
</div>
</section>`;
}
// ─── страница целиком ─────────────────────────────────────
function buildHtml(rooms, sections) {
const h = cfg.hero;
const doneCount = sections.filter((s) => s.hasContent).length;
const nav = [{ id: cfg.plan.id, nav: cfg.plan.nav, navShort: cfg.plan.navShort }, ...sections]
.map((s) => `<a href="#${s.id}"><span class="lg">${esc(s.nav)}</span><span class="sm">${esc(s.navShort || s.nav)}</span></a>`).join('\n ');
const materials = h.materials.map((m) => `<span class="swatch"><i style="background:${esc(m.hex)}"></i>${esc(m.name)}</span>`).join('\n ');
const stats = h.stats.map((s) => {
const num = /концепт/i.test(s.lbl) ? String(doneCount) : s.num;
return `<div><span class="num">${esc(num)}</span><span class="lbl">${esc(s.lbl)}</span></div>`;
}).join('\n ');
const sectionsHtml = sections.map(renderSection).join('\n\n');
return `<!DOCTYPE html>
<html lang="ru" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${esc(cfg.title)}</title>
<meta name="description" content="${esc(cfg.description)}">
<meta name="theme-color" content="#EDE8DE">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Spectral:ital,wght@0,400;0,500;0,600;1,400;1,500&family=Onest:wght@400;500;600&display=swap&subset=cyrillic,cyrillic-ext,latin" rel="stylesheet">
<link rel="stylesheet" href="css/style.css?v=${CSS_VER}">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='20' fill='%237C8A66'/><path d='M25 70V42l25-18 25 18v28H56V52H44v18z' fill='%23F7F3EA'/></svg>">
</head>
<body>
<header class="topbar" id="topbar">
<div class="topbar__inner">
<div class="brand"><b>${esc(cfg.brand)}</b> <span>${esc(cfg.brandMeta)}</span></div>
<nav class="nav" id="nav">
${nav}
</nav>
<button class="theme-toggle" id="themeBtn" aria-label="Сменить тему">
<svg class="moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z"/></svg>
<svg class="sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="12" r="4.2"/><path d="M12 2v2.5M12 19.5V22M4.9 4.9l1.8 1.8M17.3 17.3l1.8 1.8M2 12h2.5M19.5 12H22M4.9 19.1l1.8-1.8M17.3 6.7l1.8-1.8"/></svg>
</button>
</div>
</header>
<section class="hero">
<div class="wrap">
<div class="hero__grid">
<div class="reveal">
<span class="eyebrow">${esc(h.eyebrow)}</span>
<h1 class="hero__title">${h.title}</h1>
<p class="hero__lead">${esc(h.lead)}</p>
<div class="swatches" aria-label="Материалы квартиры">
${materials}
</div>
<div class="hero__meta">
${stats}
</div>
</div>
<div class="hero__plan reveal">
<span class="cap">Планировка</span>
<img src="${imgSrc(h.planImage)}" alt="Планировка квартиры" data-zoom>
</div>
</div>
</div>
</section>
${renderPlan(rooms, sections)}
${sectionsHtml}
<footer class="footer">
<div class="wrap footer__inner">
<span><b>${esc(cfg.brand)}</b> · концепт ремонта 2026</span>
<span>Собрано из заметок Obsidian · 60 м²</span>
</div>
</footer>
<div class="lightbox" id="lightbox" aria-hidden="true">
<button class="lightbox__close" id="lbClose" aria-label="Закрыть">×</button>
<img id="lbImg" src="" alt="">
</div>
<script src="js/app.js?v=${JS_VER}"></script>
</body>
</html>`;
}
// ─── оптимизация картинок ─────────────────────────────────
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 rooms = parsePlanRooms();
const skip = new Set(['README.md', cfg.plan.file]);
const files = fs.readdirSync(ROOT).filter((f) => /\.md$/i.test(f) && !skip.has(f));
const sections = files.map((f) => buildSection(f, rooms)).sort((a, b) => a.order - b.order);
sections.forEach((s, i) => { s.index = String(i + 2).padStart(2, '0'); });
const html = buildHtml(rooms, sections); // наполняет 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(` разделов: ${sections.length} (${sections.map((s) => s.title + (s.hasContent ? '' : '·в работе')).join(', ')})`);
console.log(` картинок: ${usedImages.size}`);
})().catch((e) => { console.error('❌', e); process.exit(1); });