From a61044a04b487953aa603934996cbfddc098d33d Mon Sep 17 00:00:00 2001 From: Cosmo Date: Wed, 15 Jul 2026 21:04:49 +0000 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=BC=D0=BE=D0=BD=D1=82:=20=D0=B0?= =?UTF-8?q?=D0=B2=D1=82=D0=BE-=D0=BE=D0=B1=D0=BD=D0=B0=D1=80=D1=83=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=BE=D0=B2=20=D0=B8=D0=B7=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BE=D0=BA=20(=D0=BD=D0=B5=20=D0=BD=D1=83=D0=B6=D0=B5=D0=BD?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - одна заметка = один раздел; номер/площадь берутся из таблицы Планировки по названию - оформление через необязательный frontmatter (navShort, sub, swatches) - пустая заметка = раздел «в работе» (Кабинет добавлен) - галерея 2×2 для 4 рендеров, плейсхолдер для пустых разделов - README: подробная инструкция как добавлять комнаты Co-Authored-By: Claude Opus 4.8 (1M context) --- Ремонт квартира/.site/assets/style.css | 11 +- Ремонт квартира/.site/generate.js | 265 +++++++++++++++---------- Ремонт квартира/.site/site.config.json | 46 +---- Ремонт квартира/README.md | 50 ++++- Ремонт квартира/Гостиная-кухня.md | 5 + Ремонт квартира/Кабинет.md | 7 + Ремонт квартира/Спальня.md | 4 + 7 files changed, 231 insertions(+), 157 deletions(-) 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 - ? `
${esc(name)}
` - : ''; + ? `
${esc(name)}
` : ''; 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 `
${head}${body}
`; } @@ -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 `
    -
      ${li}
    `; +
      ${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}
    ` : ''}
    - +
    - ${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)}

    - +
    Схема планировки
    -
      ${rooms}
    +
      ${items}
    `; } // ─── страница целиком ───────────────────────────────────── -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}