Files
obsidian/Ремонт квартира/.site/generate.js
Cosmo f5c2010291
All checks were successful
Deploy Remont Site / deploy (push) Successful in 16s
Генератор: таблицы с колонкой «Комментарий» остаются таблицей (с миниатюрами фото), а не карточками
Карточки — для небольших таблиц-выборов с фото без комментариев (диваны, столы).
Большие сравнительные таблицы (техника) при добавлении фото показывают превью прямо в таблице.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 18:08:32 +00:00

437 lines
19 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
/*
* Генератор сайта «Ремонт квартиры».
* Читает 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, '&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>');
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(/(?<!\\)\|/).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 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
? `<div class="opt__media" data-zoom-wrap><img src="${imgSrc(photo)}" alt="${esc(name)}" 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(name)}</span>
${price}${link}</div></article>`;
}).join('\n');
return `<div class="options">${cards}</div>`;
}
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' : '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 renderVariants(items) {
const li = 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');
return `<ol class="variants reveal">${li}</ol>`;
}
// картинка сразу перед списком → блок «отделки» (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 `<li><span><b>${inline(b)}</b>${small ? `<small>${inline(small)}</small>` : ''}</span></li>`;
}).join('\n');
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">${li}</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>`;
}
// ─── сборка секции ────────────────────────────────────────
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(`<p class="note reveal">${inline(b.text)}</p>`);
} else if (b.t === 'p') {
out.push(`<p class="prose reveal">${inline(b.text)}</p>`);
}
}
const sw = (sec.swatches || []).map((s) =>
`<span class="swatch"><i style="background:${esc(s.hex)}"></i>${esc(s.name)}</span>`).join('');
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(title)}</h2>
${lead ? `<p class="section__lead">${inline(lead)}</p>` : ''}
${sw ? `<div class="swatches">${sw}</div>` : ''}
</div>
<div class="section__index" aria-hidden="true">${esc(sec.index || '')}</div>
</div>
${out.join('\n ')}
</div>
</section>`;
}
// ─── секция планировки (таблица комнат из заметки) ───────
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 = `<span class="room-name">${esc(name)}${m.sub ? `<small>${esc(m.sub)}</small>` : ''}</span>`;
const nameCell = m.link ? `<a class="rooms-link" href="${esc(m.link)}">${nameHtml}</a>` : nameHtml;
const tag = m.tag === 'done' ? '<span class="tag tag--done">концепт готов</span>'
: m.tag === 'wip' ? '<span class="tag tag--wip">в работе</span>' : '';
return `<li><span class="room-no">${esc(no)}</span>${nameCell}${tag}<span class="room-area">${esc(area)}</span></li>`;
}).join('\n');
}
return `<section class="section" id="${esc(plan.id)}">
<div class="wrap">
<div class="section__head reveal">
<div>
<span class="eyebrow">${esc(plan.eyebrow)}</span>
<h2>${esc(plan.nav)}</h2>
<p class="section__lead">${esc(plan.lead)}</p>
</div>
<div class="section__index" aria-hidden="true">${esc(plan.index)}</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">${rooms}</ul>
</div>
</div>
</section>`;
}
// ─── страница целиком ─────────────────────────────────────
function buildHtml() {
const h = cfg.hero;
const nav = [cfg.plan, ...cfg.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) =>
`<div><span class="num">${esc(s.num)}</span><span class="lbl">${esc(s.lbl)}</span></div>`).join('\n ');
const sections = cfg.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">
<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(cfg.plan)}
${sections}
<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"></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 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); });