Ремонт: авто-обнаружение разделов из заметок (не нужен конфиг)
All checks were successful
Deploy Remont Site / deploy (push) Successful in 17s

- одна заметка = один раздел; номер/площадь берутся из таблицы Планировки по названию
- оформление через необязательный frontmatter (navShort, sub, swatches)
- пустая заметка = раздел «в работе» (Кабинет добавлен)
- галерея 2×2 для 4 рендеров, плейсхолдер для пустых разделов
- README: подробная инструкция как добавлять комнаты

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cosmo
2026-07-15 21:04:49 +00:00
parent dd8678a426
commit a61044a04b
7 changed files with 231 additions and 157 deletions

View File

@@ -310,9 +310,16 @@ a.rooms-link:hover .room-name { color: var(--sage-deep); }
.variants small { color: var(--ink-soft); display: block; font-size: 13.5px; } .variants small { color: var(--ink-soft); display: block; font-size: 13.5px; }
@media (max-width: 860px) { .finish { grid-template-columns: 1fr; } } @media (max-width: 860px) { .finish { grid-template-columns: 1fr; } }
/* парный ряд рендеров */ /* парный ряд / сетка 2×2 рендеров */
.renders--duo { grid-template-columns: repeat(2, 1fr); margin-top: 18px; } .renders--duo { grid-template-columns: repeat(2, 1fr); margin-top: 18px; }
@media (max-width: 720px) { .renders--duo { grid-template-columns: 1fr; } } .renders--quad { grid-template-columns: repeat(2, 1fr); margin-top: 18px; }
@media (max-width: 720px) { .renders--duo, .renders--quad { grid-template-columns: 1fr; } }
/* пустой раздел (в работе) */
.section-empty {
margin-top: 36px; padding: 28px 30px; border: 1px dashed var(--line-strong); border-radius: var(--r);
color: var(--ink-soft); font-size: 16px; background: color-mix(in srgb, var(--sage) 5%, transparent);
}
/* проза и заметки из markdown */ /* проза и заметки из markdown */
.prose { max-width: 62ch; color: var(--ink-soft); margin: 22px 0 0; } .prose { max-width: 62ch; color: var(--ink-soft); margin: 22px 0 0; }

View File

@@ -1,9 +1,27 @@
#!/usr/bin/env node #!/usr/bin/env node
/* /*
* Генератор сайта «Ремонт квартиры». * Генератор сайта «Ремонт квартиры».
* Читает markdown-заметки из родительской папки vault и собирает статический сайт *
* в OUT. Дизайн — из assets/. Контент (рендеры, карточки мебели, варианты) берётся * КАК ЭТО РАБОТАЕТ (чтобы было понятно):
* из самих заметок, поэтому правка заметки → обновление сайта после пересборки. * • Каждая заметка *.md в папке (кроме README.md и Планировки) = один раздел сайта.
* • Номер комнаты и площадь берутся из таблицы в «Планировка.md» по совпадению
* названия заметки с названием комнаты. Ничего в конфиге прописывать не нужно —
* добавил заметку → появился раздел.
* • Оформление раздела задаётся необязательным frontmatter в начале заметки:
* ---
* navShort: Кухня # короткая подпись в меню (для узких экранов)
* sub: кухня-студия с зоной отдыха # подпись под комнатой в списке планировки
* swatches: Дуб #C7A579, Графит #4B4E52 # кружки-образцы материалов
* ---
* • Тело заметки рендерится по правилам:
* - подряд идущие ![[картинка]] → галерея рендеров (1 / 2 / 3 / 4 в ряд);
* - таблица с колонкой «Фото» и без «Комментарий» → карточки (диваны, столы);
* - таблица с «Комментарий» → сравнительная таблица (техника), фото как миниатюры;
* - картинка сразу перед списком → блок «вариант отделки»;
* - обычные списки → нумерованные варианты.
* • Пустая заметка → раздел «в работе», комната в планировке помечается соответственно.
*
* Общие настройки (шапка, палитра, вводный текст) — в site.config.json.
*/ */
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
@@ -27,6 +45,13 @@ function inline(t) {
return t; 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(); const usedImages = new Set();
function imgSrc(name) { function imgSrc(name) {
name = String(name).trim(); name = String(name).trim();
@@ -47,12 +72,31 @@ function domainLabel(url) {
try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return 'сайт'; } try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return 'сайт'; }
} }
function extractUrl(cell) { function extractUrl(cell) {
const md = cell.match(/\]\(([^)]+)\)/); // [text](url) const md = cell.match(/\]\(([^)]+)\)/);
if (md) return md[1].trim(); if (md) return md[1].trim();
const bare = cell.match(/https?:\/\/\S+/); const bare = cell.match(/https?:\/\/\S+/);
return bare ? bare[0].trim() : ''; 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 в блоки ───────────────────────────── // ─── парсер markdown в блоки ─────────────────────────────
function parseBlocks(md) { function parseBlocks(md) {
const lines = md.replace(/\r/g, '').split('\n'); const lines = md.replace(/\r/g, '').split('\n');
@@ -111,7 +155,6 @@ function parseBlocks(md) {
} }
function parseTable(rows) { function parseTable(rows) {
// split по неэкранированному «|», затем восстанавливаем «\|» → «|» (для ![[img|width]] внутри ячеек)
const cells = (r) => r.trim().replace(/^\|/, '').replace(/\|$/, '') const cells = (r) => r.trim().replace(/^\|/, '').replace(/\|$/, '')
.split(/(?<!\\)\|/).map((c) => c.replace(/\\\|/g, '|').trim()); .split(/(?<!\\)\|/).map((c) => c.replace(/\\\|/g, '|').trim());
const headers = cells(rows[0]); const headers = cells(rows[0]);
@@ -119,22 +162,17 @@ function parseTable(rows) {
return { t: 'table', headers, rows: body }; return { t: 'table', headers, rows: body };
} }
// ─── распознавание таблицы-«прайса» → карточки ─────────── // ─── типы таблиц ─────────────────────────────────────────
function tableIsPriced(tbl) { function tableIsPriced(tbl) { return tbl.headers.some((h) => /цена|price||руб/i.test(h)); }
return tbl.headers.some((h) => /цена|price||руб/i.test(h));
}
function tableHasPhoto(tbl) { function tableHasPhoto(tbl) {
return tbl.headers.some((h) => /фото|photo|картин|image/i.test(h)) return tbl.headers.some((h) => /фото|photo|картин|image/i.test(h))
|| tbl.rows.some((r) => r.some((c) => parseWiki(c))); || tbl.rows.some((r) => r.some((c) => parseWiki(c)));
} }
function tableHasComment(tbl) { function tableHasComment(tbl) { return tbl.headers.some((h) => /коммент|comment|примеч|описан/i.test(h)); }
return tbl.headers.some((h) => /коммент|comment|примеч|описан/i.test(h));
}
// карточки — для небольших таблиц-выборов с фото и без колонки «Комментарий» (диваны, столы).
// большие сравнительные таблицы (техника: есть «Комментарий») остаются таблицей — с миниатюрами, если добавить фото.
function tableIsCards(tbl) { return tableIsPriced(tbl) && tableHasPhoto(tbl) && !tableHasComment(tbl); } function tableIsCards(tbl) { return tableIsPriced(tbl) && tableHasPhoto(tbl) && !tableHasComment(tbl); }
function colIndex(headers, re) { return headers.findIndex((h) => re.test(h)); } function colIndex(headers, re) { return headers.findIndex((h) => re.test(h)); }
// ─── рендер блоков ───────────────────────────────────────
function renderCards(tbl) { function renderCards(tbl) {
const H = tbl.headers; const H = tbl.headers;
const iNo = colIndex(H, /^№|^no$|^#$/i); const iNo = colIndex(H, /^№|^no$|^#$/i);
@@ -147,18 +185,16 @@ function renderCards(tbl) {
const no = iNo >= 0 && r[iNo] ? r[iNo] : String(idx + 1); const no = iNo >= 0 && r[iNo] ? r[iNo] : String(idx + 1);
const name = iName >= 0 ? r[iName] : ''; const name = iName >= 0 ? r[iName] : '';
const priceRaw = iPrice >= 0 ? r[iPrice] : ''; const priceRaw = iPrice >= 0 ? r[iPrice] : '';
const priceNum = (priceRaw.match(/[\d\s .]+/) || [''])[0].trim(); const priceNum = (priceRaw.match(/[\d\s .]+/) || [''])[0].trim();
const unit = /₽|руб/i.test(priceRaw) ? '₽' : ''; const unit = /₽|руб/i.test(priceRaw) ? '₽' : '';
const photo = iPhoto >= 0 ? parseWiki(r[iPhoto]) : null; const photo = iPhoto >= 0 ? parseWiki(r[iPhoto]) : null;
const url = iLink >= 0 ? extractUrl(r[iLink]) : ''; const url = iLink >= 0 ? extractUrl(r[iLink]) : '';
const media = photo const media = photo
? `<div class="opt__media" data-zoom-wrap><img src="${imgSrc(photo)}" alt="${esc(name)}" data-zoom loading="lazy"></div>` ? `<div class="opt__media" data-zoom-wrap><img src="${imgSrc(photo)}" alt="${esc(name)}" data-zoom loading="lazy"></div>` : '';
: '';
const price = priceNum const price = priceNum
? `<span class="opt__price">${esc(priceNum)}${unit ? ` <span>${unit}</span>` : ''}</span>` : ''; ? `<span class="opt__price">${esc(priceNum)}${unit ? ` <span>${unit}</span>` : ''}</span>` : '';
const link = url 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>` ? `<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"> return `<article class="opt reveal">${media}<div class="opt__body">
<span class="opt__no">Вариант ${esc(no)}</span> <span class="opt__no">Вариант ${esc(no)}</span>
<span class="opt__name">${inline(name)}</span> <span class="opt__name">${inline(name)}</span>
@@ -175,9 +211,7 @@ function tableCell(c) {
} }
function renderTable(tbl) { function renderTable(tbl) {
const head = tbl.headers.map((h) => `<th>${inline(h)}</th>`).join(''); const head = tbl.headers.map((h) => `<th>${inline(h)}</th>`).join('');
const body = tbl.rows.map((r) => const body = tbl.rows.map((r) => `<tr>${r.map((c) => `<td>${tableCell(c)}</td>`).join('')}</tr>`).join('\n');
`<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>`; return `<div class="table-wrap reveal"><table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`;
} }
@@ -185,59 +219,37 @@ function renderImages(imgs) {
if (imgs.length === 1) { 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>`; 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 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(''); 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>`; return `<div class="renders ${cls} reveal">${cells}</div>`;
} }
function renderVariants(items) { function variantItems(items) {
const li = items.map((it) => { return items.map((it) => {
const parts = it.split(/\s+[—–-]\s+/); const parts = it.split(/\s+[—–-]\s+/);
const b = parts.shift(); const b = parts.shift();
const small = parts.join(' — '); const small = parts.join(' — ');
return `<li><span><b>${inline(b)}</b>${small ? `<small>${inline(small)}</small>` : ''}</span></li>`; return `<li><span><b>${inline(b)}</b>${small ? `<small>${inline(small)}</small>` : ''}</span></li>`;
}).join('\n'); }).join('\n');
return `<ol class="variants reveal">${li}</ol>`;
} }
function renderVariants(items) { return `<ol class="variants reveal">${variantItems(items)}</ol>`; }
// картинка сразу перед списком → блок «отделки» (image | варианты)
function renderFinish(img, items) { 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"> return `<div class="finish reveal">
<div class="finish__img" data-zoom-wrap><img src="${imgSrc(img)}" alt="" data-zoom loading="lazy"></div> <div class="finish__img" data-zoom-wrap><img src="${imgSrc(img)}" alt="" data-zoom loading="lazy"></div>
<ol class="variants">${li}</ol></div>`; <ol class="variants">${variantItems(items)}</ol></div>`;
} }
function subhead(text, count) { function subhead(text, count) {
const c = count != null const c = count != null
? `<span class="count">${count} ${plural(count, 'вариант', 'варианта', 'вариантов')}</span>` : ''; ? `<span class="count">${count} ${plural(count, 'вариант', 'варианта', 'вариантов')}</span>` : '';
return `<div class="subhead reveal"><h3>${inline(text)}</h3>${c}</div>`; return `<div class="subhead reveal"><h3>${inline(text)}</h3>${c}</div>`;
} }
// ─── сборка секции ──────────────────────────────────────── // тело раздела (список блоков → html)
function renderSection(sec) { function renderBody(body) {
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 = []; const out = [];
for (let i = 0; i < body.length; i++) { for (let i = 0; i < body.length; i++) {
const b = body[i]; const b = body[i];
const next = body[i + 1]; const next = body[i + 1];
if (b.t === 'h') { if (b.t === 'h') {
let count = null; let count = null;
if (next) { if (next) {
@@ -247,12 +259,8 @@ function renderSection(sec) {
} }
out.push(subhead(b.text, count)); out.push(subhead(b.text, count));
} else if (b.t === 'images') { } else if (b.t === 'images') {
if (next && next.t === 'list' && b.imgs.length === 1) { if (next && next.t === 'list' && b.imgs.length === 1) { out.push(renderFinish(b.imgs[0], next.items)); i++; }
out.push(renderFinish(b.imgs[0], next.items)); else out.push(renderImages(b.imgs));
i++; // список поглощён
} else {
out.push(renderImages(b.imgs));
}
} else if (b.t === 'table') { } else if (b.t === 'table') {
out.push(tableIsCards(b) ? renderCards(b) : renderTable(b)); out.push(tableIsCards(b) ? renderCards(b) : renderTable(b));
} else if (b.t === 'list') { } else if (b.t === 'list') {
@@ -263,78 +271,116 @@ function renderSection(sec) {
out.push(`<p class="prose reveal">${inline(b.text)}</p>`); out.push(`<p class="prose reveal">${inline(b.text)}</p>`);
} }
} }
return out.join('\n ');
}
const sw = (sec.swatches || []).map((s) => // ─── планировка: разбор таблицы комнат ───────────────────
`<span class="swatch"><i style="background:${esc(s.hex)}"></i>${esc(s.name)}</span>`).join(''); 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)}"> return `<section class="section" id="${esc(sec.id)}">
<div class="wrap"> <div class="wrap">
<div class="section__head reveal"> <div class="section__head reveal">
<div> <div>
<span class="eyebrow">${esc(sec.eyebrow || '')}</span> <span class="eyebrow">${esc(sec.eyebrow)}</span>
<h2>${esc(title)}</h2> <h2>${esc(sec.title)}</h2>
${lead ? `<p class="section__lead">${inline(lead)}</p>` : ''} ${sec.lead ? `<p class="section__lead">${inline(sec.lead)}</p>` : ''}
${sw ? `<div class="swatches">${sw}</div>` : ''} ${sw ? `<div class="swatches">${sw}</div>` : ''}
</div> </div>
<div class="section__index" aria-hidden="true">${esc(sec.index || '')}</div> <div class="section__index" aria-hidden="true">${esc(sec.index)}</div>
</div> </div>
${out.join('\n ')} ${bodyHtml}
</div> </div>
</section>`; </section>`;
} }
// ─── секция планировки (таблица комнат из заметки) ─────── function renderPlan(rooms, sections) {
function renderPlan(plan) { const byRoom = new Map();
const md = fs.readFileSync(path.join(ROOT, plan.file), 'utf8'); sections.forEach((s) => { if (s.room) byRoom.set(s.room.no, s); });
const blocks = parseBlocks(md); const items = rooms.map((r) => {
const tbl = blocks.find((b) => b.t === 'table'); const sec = byRoom.get(r.no);
let rooms = ''; const nameHtml = `<span class="room-name">${esc(r.name)}${r.sub || (sec && sec.sub) ? `<small>${esc(r.sub || sec.sub)}</small>` : ''}</span>`;
if (tbl) { const nameCell = sec ? `<a class="rooms-link" href="#${esc(sec.id)}">${nameHtml}</a>` : nameHtml;
const H = tbl.headers; const tag = sec ? (sec.hasContent
const iNo = colIndex(H, /^№|^no$|^#$/i); ? '<span class="tag tag--done">концепт готов</span>'
const iArea = colIndex(H, /площад|м²|area/i); : '<span class="tag tag--wip">в работе</span>') : '';
const iName = H.findIndex((h, idx) => idx !== iNo && idx !== iArea && h); return `<li><span class="room-no">${esc(r.no)}</span>${nameCell}${tag}<span class="room-area">${esc(r.area)}</span></li>`;
rooms = tbl.rows.map((r) => { }).join('\n');
const no = r[iNo] || ''; return `<section class="section" id="${esc(cfg.plan.id)}">
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="wrap">
<div class="section__head reveal"> <div class="section__head reveal">
<div> <div>
<span class="eyebrow">${esc(plan.eyebrow)}</span> <span class="eyebrow">${esc(cfg.plan.eyebrow)}</span>
<h2>${esc(plan.nav)}</h2> <h2>${esc(cfg.plan.nav)}</h2>
<p class="section__lead">${esc(plan.lead)}</p> <p class="section__lead">${esc(cfg.plan.lead)}</p>
</div> </div>
<div class="section__index" aria-hidden="true">${esc(plan.index)}</div> <div class="section__index" aria-hidden="true">01</div>
</div> </div>
<div class="plan-layout"> <div class="plan-layout">
<figure class="plan-figure reveal"><img src="${imgSrc(cfg.hero.planImage)}" alt="Схема планировки" data-zoom></figure> <figure class="plan-figure reveal"><img src="${imgSrc(cfg.hero.planImage)}" alt="Схема планировки" data-zoom></figure>
<ul class="rooms reveal">${rooms}</ul> <ul class="rooms reveal">${items}</ul>
</div> </div>
</div> </div>
</section>`; </section>`;
} }
// ─── страница целиком ───────────────────────────────────── // ─── страница целиком ─────────────────────────────────────
function buildHtml() { function buildHtml(rooms, sections) {
const h = cfg.hero; const h = cfg.hero;
const nav = [cfg.plan, ...cfg.sections].map((s) => const doneCount = sections.filter((s) => s.hasContent).length;
`<a href="#${s.id}"><span class="lg">${esc(s.nav)}</span><span class="sm">${esc(s.navShort || s.nav)}</span></a>`).join('\n '); const nav = [{ id: cfg.plan.id, nav: cfg.plan.nav, navShort: cfg.plan.navShort }, ...sections]
const materials = h.materials.map((m) => .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 ');
`<span class="swatch"><i style="background:${esc(m.hex)}"></i>${esc(m.name)}</span>`).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 stats = h.stats.map((s) => {
`<div><span class="num">${esc(s.num)}</span><span class="lbl">${esc(s.lbl)}</span></div>`).join('\n '); 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>`;
const sections = cfg.sections.map(renderSection).join('\n\n'); }).join('\n ');
const sectionsHtml = sections.map(renderSection).join('\n\n');
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html lang="ru" data-theme="light"> <html lang="ru" data-theme="light">
@@ -387,9 +433,9 @@ function buildHtml() {
</div> </div>
</section> </section>
${renderPlan(cfg.plan)} ${renderPlan(rooms, sections)}
${sections} ${sectionsHtml}
<footer class="footer"> <footer class="footer">
<div class="wrap footer__inner"> <div class="wrap footer__inner">
@@ -425,12 +471,19 @@ async function optimizeImages() {
fs.mkdirSync(path.join(OUT, 'css'), { recursive: true }); fs.mkdirSync(path.join(OUT, 'css'), { recursive: true });
fs.mkdirSync(path.join(OUT, 'js'), { recursive: true }); fs.mkdirSync(path.join(OUT, 'js'), { recursive: true });
const html = buildHtml(); // наполняет usedImages 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.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/style.css'), path.join(OUT, 'css/style.css'));
fs.copyFileSync(path.join(HERE, 'assets/app.js'), path.join(OUT, 'js/app.js')); fs.copyFileSync(path.join(HERE, 'assets/app.js'), path.join(OUT, 'js/app.js'));
await optimizeImages(); await optimizeImages();
console.log(`✅ Сайт собран: ${OUT}`); console.log(`✅ Сайт собран: ${OUT}`);
console.log(` картинок: ${usedImages.size}, секций: ${cfg.sections.length}`); console.log(` разделов: ${sections.length} (${sections.map((s) => s.title + (s.hasContent ? '' : '·в работе')).join(', ')})`);
console.log(` картинок: ${usedImages.size}`);
})().catch((e) => { console.error('❌', e); process.exit(1); }); })().catch((e) => { console.error('❌', e); process.exit(1); });

View File

@@ -1,4 +1,5 @@
{ {
"_комментарий": "Только ОБЩИЕ настройки сайта. Разделы берутся автоматически из заметок *.md (см. generate.js).",
"title": "Ремонт квартиры — концепт", "title": "Ремонт квартиры — концепт",
"description": "Концепт ремонта двухкомнатной квартиры 60 м²: планировка, интерьеры, варианты мебели и отделки.", "description": "Концепт ремонта двухкомнатной квартиры 60 м²: планировка, интерьеры, варианты мебели и отделки.",
"brand": "Квартира", "brand": "Квартира",
@@ -18,7 +19,7 @@
"stats": [ "stats": [
{ "num": "2", "lbl": "комнаты" }, { "num": "2", "lbl": "комнаты" },
{ "num": "60", "lbl": "м² общая" }, { "num": "60", "lbl": "м² общая" },
{ "num": "2", "lbl": "концепта готово" } { "num": "0", "lbl": "концепта готово" }
] ]
}, },
"plan": { "plan": {
@@ -27,45 +28,6 @@
"nav": "Планировка", "nav": "Планировка",
"navShort": "План", "navShort": "План",
"eyebrow": "Обзор", "eyebrow": "Обзор",
"index": "01", "lead": "Номера на схеме совпадают с номерами комнат в списке. Готовые концепты кликабельны."
"lead": "Шесть помещений и балкон. Номера на схеме совпадают с номерами комнат ниже.", }
"meta": {
"3": { "sub": "кухня-студия с зоной отдыха", "link": "#living", "tag": "done" },
"1": { "sub": "основная, у окна", "link": "#bedroom", "tag": "done" },
"2": { "sub": "рабочее место, концепт в работе", "tag": "wip" },
"4": { "sub": "коридор" },
"5": {},
"6": {}
}
},
"sections": [
{
"id": "living",
"file": "Гостиная-кухня.md",
"nav": "Гостиная-кухня",
"navShort": "Кухня",
"eyebrow": "Комната 3 · 17,0 м²",
"index": "02",
"swatches": [
{ "name": "Шалфей", "hex": "#7C8A66" },
{ "name": "Дуб", "hex": "#C7A579" },
{ "name": "Кремовый", "hex": "#EDE8DE" },
{ "name": "Графит", "hex": "#4B4E52" }
]
},
{
"id": "bedroom",
"file": "Спальня.md",
"nav": "Спальня",
"navShort": "Спальня",
"eyebrow": "Комната 1 · 14,9 м²",
"index": "03",
"swatches": [
{ "name": "Голубой", "hex": "#97B0C3" },
{ "name": "Дуб", "hex": "#C7A579" },
{ "name": "Кремовый", "hex": "#EDE8DE" },
{ "name": "Серый", "hex": "#8C8B86" }
]
}
]
} }

View File

@@ -1,16 +1,52 @@
# Ремонт квартиры # Ремонт квартиры
Концепт ремонта двухкомнатной квартиры, **60 м²**. Планировка, рендеры интерьеров, варианты мебели и отделки. Концепт ремонта двухкомнатной квартиры, **60 м²**. Сайт: **https://remont.digital-home.site**
Сайт **собирается автоматически из этих заметок** при каждом пуше в git (~1 мин).
Правишь заметку в Obsidian → синк → сайт обновился. Ничего вручную деплоить не нужно.
## Разделы ## Разделы
- [[Планировка]] - [[Планировка]] — таблица комнат (номер, название, площадь). Это «оглавление» квартиры.
- [[Гостиная-кухня]]
- [[Спальня]] - [[Спальня]]
- [[Кабинет]]
- [[Гостиная-кухня]]
## Сайт ## Как добавить новый раздел (комнату)
Онлайн-версия собирается автоматически из этих заметок при каждом пуше в git: 1. Убедись, что комната есть в таблице в [[Планировка]] (номер, название, площадь).
**https://remont.digital-home.site** 2. Создай заметку с **точно таким же названием**, как в таблице (напр. `Кабинет.md`).
Номер комнаты и площадь для заголовка подтянутся из таблицы автоматически.
3. Пиши контент (см. ниже). Пустая заметка = раздел «в работе».
> Сборка сайта живёт в скрытой папке `.site/` (Obsidian её не показывает). Порядок разделов на сайте = порядок строк в таблице планировки.
## Что во что превращается на сайте
- Подряд идущие картинки `![[файл.png]]`**галерея рендеров** (1 крупно / 2 / 3 / 4 в сетке).
- **Таблица с колонкой «Фото» и без «Комментарий»** → красивые **карточки** (диваны, столы).
Колонки: `№ | Название | Цена | Ссылка | Фото`. Ссылка — просто URL, кнопка подпишется сама.
- **Таблица с колонкой «Комментарий»** → аккуратная **сравнительная таблица** (техника).
Если добавить колонку «Фото» — появятся миниатюры прямо в таблице.
- Картинка сразу перед нумерованным списком → блок **«вариант отделки»** (фото слева, список справа).
- Обычный нумерованный/маркированный список → **нумерованные варианты**.
- `## Подзаголовок` → разделитель внутри раздела (с автосчётчиком «N вариантов»).
## Оформление раздела (необязательный frontmatter)
В самом верху заметки, между `---`:
```
---
navShort: Кухня # короткая подпись в меню (узкие экраны)
sub: кухня-студия с зоной отдыха # подпись под комнатой в списке планировки
swatches: Дуб #C7A579, Графит #4B4E52 # кружки-образцы материалов в шапке раздела
---
```
Всё это необязательно — без frontmatter раздел тоже соберётся.
## Техническое
- Движок сборки — в скрытой папке `.site/` (Obsidian её не показывает): `generate.js` (парсер), `site.config.json` (только общие настройки: шапка, палитра, вводный текст), `assets/` (дизайн), `Dockerfile`, `nginx.conf`.
- Деплой — Gitea Actions (`.gitea/workflows/deploy-remont.yml`) по пушу в эту папку.

View File

@@ -1,3 +1,8 @@
---
navShort: Кухня
sub: кухня-студия с зоной отдыха
swatches: Шалфей #7C8A66, Дуб #C7A579, Кремовый #EDE8DE, Графит #4B4E52
---
# Гостиная-кухня # Гостиная-кухня
Кухня-студия с зоной отдыха. Шалфейные фасады, дуб, столешница под мрамор, тёмный пол. Кухня-студия с зоной отдыха. Шалфейные фасады, дуб, столешница под мрамор, тёмный пол.

View File

@@ -0,0 +1,7 @@
---
sub: рабочее место
swatches: Дуб #C7A579, Графит #4B4E52, Кремовый #EDE8DE
---
# Кабинет
Рабочее место и место для хранения. Концепт пока собираем — скоро появятся рендеры и варианты мебели.

View File

@@ -1,3 +1,7 @@
---
sub: основная, у окна
swatches: Голубой #97B0C3, Дуб #C7A579, Кремовый #EDE8DE, Серый #8C8B86
---
# Спальня # Спальня
Спокойная: пыльно-голубые стены, светлый дуб и мягкий текстиль. Спокойная: пыльно-голубые стены, светлый дуб и мягкий текстиль.