diff --git a/Ремонт квартира/.site/assets/style.css b/Ремонт квартира/.site/assets/style.css
index a3cf2f0..487eb29 100644
--- a/Ремонт квартира/.site/assets/style.css
+++ b/Ремонт квартира/.site/assets/style.css
@@ -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; }
@media (max-width: 860px) { .finish { grid-template-columns: 1fr; } }
-/* парный ряд рендеров */
+/* парный ряд / сетка 2×2 рендеров */
.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 */
.prose { max-width: 62ch; color: var(--ink-soft); margin: 22px 0 0; }
diff --git a/Ремонт квартира/.site/generate.js b/Ремонт квартира/.site/generate.js
index 77bb63a..685c572 100644
--- a/Ремонт квартира/.site/generate.js
+++ b/Ремонт квартира/.site/generate.js
@@ -1,9 +1,27 @@
#!/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 path = require('path');
@@ -27,6 +45,13 @@ function inline(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();
function imgSrc(name) {
name = String(name).trim();
@@ -47,12 +72,31 @@ function domainLabel(url) {
try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return 'сайт'; }
}
function extractUrl(cell) {
- const md = cell.match(/\]\(([^)]+)\)/); // [text](url)
+ 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');
@@ -111,7 +155,6 @@ function parseBlocks(md) {
}
function parseTable(rows) {
- // split по неэкранированному «|», затем восстанавливаем «\|» → «|» (для ![[img|width]] внутри ячеек)
const cells = (r) => r.trim().replace(/^\|/, '').replace(/\|$/, '')
.split(/(? c.replace(/\\\|/g, '|').trim());
const headers = cells(rows[0]);
@@ -119,22 +162,17 @@ function parseTable(rows) {
return { t: 'table', headers, rows: body };
}
-// ─── распознавание таблицы-«прайса» → карточки ───────────
-function tableIsPriced(tbl) {
- return tbl.headers.some((h) => /цена|price|₽|руб/i.test(h));
-}
+// ─── типы таблиц ─────────────────────────────────────────
+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 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);
@@ -147,18 +185,16 @@ function renderCards(tbl) {
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 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))} `
- : '';
+ ? `Открыть на ${esc(domainLabel(url))} ` : '';
return `${media}
Вариант ${esc(no)}
${inline(name)}
@@ -175,9 +211,7 @@ function tableCell(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');
+ const body = tbl.rows.map((r) => `
${r.map((c) => `| ${tableCell(c)} | `).join('')}
`).join('\n');
return `
`;
}
@@ -185,59 +219,37 @@ function renderImages(imgs) {
if (imgs.length === 1) {
return `
`;
}
- 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) => `
`).join('');
return `
${cells}
`;
}
-function renderVariants(items) {
- const li = items.map((it) => {
+function variantItems(items) {
+ return 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 renderVariants(items) { return `
${variantItems(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 `
${inline(b)}${small ? `${inline(small)}` : ''}`;
- }).join('\n');
return `
`;
+
${variantItems(items)}
`;
}
-
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);
-
+// тело раздела (список блоков → 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) {
@@ -247,12 +259,8 @@ function renderSection(sec) {
}
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));
- }
+ 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') {
@@ -263,78 +271,116 @@ function renderSection(sec) {
out.push(`${inline(b.text)}
`);
}
}
+ return out.join('\n ');
+}
- const sw = (sec.swatches || []).map((s) =>
- `${esc(s.name)}`).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) => `${esc(s.name)}`).join('');
+ const bodyHtml = sec.hasContent
+ ? renderBody(sec.contentBlocks)
+ : `Раздел в работе — скоро добавим рендеры и варианты мебели.
`;
return `
-
${esc(sec.eyebrow || '')}
-
${esc(title)}
- ${lead ? `
${inline(lead)}
` : ''}
+
${esc(sec.eyebrow)}
+
${esc(sec.title)}
+ ${sec.lead ? `
${inline(sec.lead)}
` : ''}
${sw ? `
${sw}
` : ''}
-
${esc(sec.index || '')}
+
${esc(sec.index)}
- ${out.join('\n ')}
+ ${bodyHtml}
`;
}
-// ─── секция планировки (таблица комнат из заметки) ───────
-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 `
+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 = `${esc(r.name)}${r.sub || (sec && sec.sub) ? `${esc(r.sub || sec.sub)}` : ''}`;
+ const nameCell = sec ? `${nameHtml}` : nameHtml;
+ const tag = sec ? (sec.hasContent
+ ? 'концепт готов'
+ : 'в работе') : '';
+ return `${esc(r.no)}${nameCell}${tag}${esc(r.area)}`;
+ }).join('\n');
+ return `
-
${esc(plan.eyebrow)}
-
${esc(plan.nav)}
-
${esc(plan.lead)}
+
${esc(cfg.plan.eyebrow)}
+
${esc(cfg.plan.nav)}
+
${esc(cfg.plan.lead)}
-
${esc(plan.index)}
+
01
})
-
+
`;
}
// ─── страница целиком ─────────────────────────────────────
-function buildHtml() {
+function buildHtml(rooms, sections) {
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');
+ 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) => `${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) => {
+ const num = /концепт/i.test(s.lbl) ? String(doneCount) : s.num;
+ return `${esc(num)}${esc(s.lbl)}
`;
+ }).join('\n ');
+ const sectionsHtml = sections.map(renderSection).join('\n\n');
return `
@@ -387,9 +433,9 @@ function buildHtml() {
-${renderPlan(cfg.plan)}
+${renderPlan(rooms, sections)}
-${sections}
+${sectionsHtml}