#!/usr/bin/env node /* * Генератор сайта «Ремонт квартиры». * * КАК ЭТО РАБОТАЕТ (чтобы было понятно): * • Каждая заметка *.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'); const crypto = require('crypto'); 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')); // хэш содержимого ассета → ?v=… чтобы браузер сбрасывал кэш при изменении CSS/JS function assetVer(rel) { const buf = fs.readFileSync(path.join(HERE, 'assets', rel)); return crypto.createHash('md5').update(buf).digest('hex').slice(0, 8); } const CSS_VER = assetVer('style.css'); const JS_VER = assetVer('app.js'); // ─── утилиты ────────────────────────────────────────────── const esc = (s) => String(s == null ? '' : s) .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); function inline(t) { t = esc(t); t = t.replace(/\*\*([^*]+)\*\*/g, '$1'); t = t.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); t = t.replace(/<br\s*\/?>/gi, '
'); // разрешаем перенос строки
в тексте заметки 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(); 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(/\]\(([^)]+)\)/); 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'); 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) { const cells = (r) => r.trim().replace(/^\|/, '').replace(/\|$/, '') .split(/(? 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 iCat = colIndex(H, /^(тип|категор|группа|техника|раздел)$/i); const iName = H.findIndex((h, idx) => idx !== iNo && idx !== iPrice && idx !== iLink && idx !== iPhoto && idx !== iCat && h); // одна карточка. no — подпись «Вариант N» (в пределах группы) const card = (r, no) => { const nameRaw = iName >= 0 ? (r[iName] || '') : ''; // «Модель
описание» → жирная модель + приглушённое описание const [head, ...rest] = nameRaw.split(//i); const desc = rest.join('
').trim(); 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 ? `
${esc(head)}
` : ''; const price = priceNum ? `${esc(priceNum)}${unit ? ` ${unit}` : ''}` : ''; const link = url ? `Открыть на ${esc(domainLabel(url))} ` : ''; return `
${media}
Вариант ${esc(no)} ${inline(head)} ${desc ? `${inline(desc)}` : ''} ${price}${link}
`; }; // без колонки типа — простая сетка карточек if (iCat < 0) { const cards = tbl.rows.map((r, idx) => card(r, iNo >= 0 && r[iNo] ? r[iNo] : idx + 1)).join('\n'); return `
${cards}
`; } // с колонкой типа — группируем по типу (пустая ячейка наследует тип сверху) const groups = []; let cur = null, lastCat = ''; tbl.rows.forEach((r) => { const cat = (r[iCat] && r[iCat].trim()) || lastCat; lastCat = cat; if (!cur || cur.cat !== cat) { cur = { cat, rows: [] }; groups.push(cur); } cur.rows.push(r); }); return groups.map((g) => { const cards = g.rows.map((r, i) => card(r, i + 1)).join('\n'); return `

${inline(g.cat)}${g.rows.length} ${plural(g.rows.length, 'вариант', 'варианта', 'вариантов')}

${cards}
`; }).join('\n'); } function tableCell(c) { const w = parseWiki(c); if (w) return ``; if (/^https?:\/\/\S+$/.test(c)) return `Открыть `; return inline(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'); return `
${head}${body}
`; } function renderImages(imgs) { if (imgs.length === 1) { return `
`; } const cls = imgs.length === 2 ? 'renders--duo' : imgs.length === 4 ? 'renders--quad' : 'renders--trio'; const cells = imgs.map((im) => `
`).join(''); return `
${cells}
`; } 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'); } function renderVariants(items) { return `
      ${variantItems(items)}
    `; } function renderFinish(img, items) { return `
      ${variantItems(items)}
    `; } function subhead(text, count) { const c = count != null ? `${count} ${plural(count, 'вариант', 'варианта', 'вариантов')}` : ''; return `

    ${inline(text)}

    ${c}
    `; } // тело раздела (список блоков → 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) { 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(`

    ${inline(b.text)}

    `); } else if (b.t === 'p') { out.push(`

    ${inline(b.text)}

    `); } } return out.join('\n '); } // ─── планировка: разбор таблицы комнат ─────────────────── 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(sec.title)}

    ${sec.lead ? `

    ${inline(sec.lead)}

    ` : ''} ${sw ? `
    ${sw}
    ` : ''}
    ${bodyHtml}
    `; } 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(cfg.plan.eyebrow)}

    ${esc(cfg.plan.nav)}

    ${esc(cfg.plan.lead)}

    Схема планировки
      ${items}
    `; } // ─── страница целиком ───────────────────────────────────── function buildHtml(rooms, sections) { const h = cfg.hero; 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 ` ${esc(cfg.title)}
    ${esc(cfg.brand)} ${esc(cfg.brandMeta)}
    ${esc(h.eyebrow)}

    ${h.title}

    ${esc(h.lead)}

    ${materials}
    ${stats}
    Планировка Планировка квартиры
    ${renderPlan(rooms, sections)} ${sectionsHtml} `; } // ─── оптимизация картинок ───────────────────────────────── 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 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.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(` разделов: ${sections.length} (${sections.map((s) => s.title + (s.hasContent ? '' : '·в работе')).join(', ')})`); console.log(` картинок: ${usedImages.size}`); })().catch((e) => { console.error('❌', e); process.exit(1); });