All checks were successful
Deploy Remont Site / deploy (push) Successful in 16s
Карточки — для небольших таблиц-выборов с фото без комментариев (диваны, столы). Большие сравнительные таблицы (техника) при добавлении фото показывают превью прямо в таблице. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
437 lines
19 KiB
JavaScript
437 lines
19 KiB
JavaScript
#!/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, '>').replace(/"/g, '"');
|
||
|
||
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); });
|