#!/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, '"'); function inline(t) { t = esc(t); t = t.replace(/\*\*([^*]+)\*\*/g, '$1'); t = t.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); 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(/(? 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 tableIsCards(tbl) { return tableIsPriced(tbl) && tableHasPhoto(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 ? `
${esc(name)}
` : ''; const price = priceNum ? `${esc(priceNum)}${unit ? ` ${unit}` : ''}` : ''; const link = url ? `Открыть на ${esc(domainLabel(url))} ` : ''; return `
${media}
Вариант ${esc(no)} ${inline(name)} ${price}${link}
`; }).join('\n'); return `
${cards}
`; } 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' : 'renders--trio'; const cells = imgs.map((im) => `
`).join(''); return `
${cells}
`; } function renderVariants(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}
    `; } // картинка сразу перед списком → блок «отделки» (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 `
  • ${inline(b)}${small ? `${inline(small)}` : ''}
  • `; }).join('\n'); return `
      ${li}
    `; } 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); 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)}

    `); } } const sw = (sec.swatches || []).map((s) => `${esc(s.name)}`).join(''); return `
    ${esc(sec.eyebrow || '')}

    ${esc(title)}

    ${lead ? `

    ${inline(lead)}

    ` : ''} ${sw ? `
    ${sw}
    ` : ''}
    ${out.join('\n ')}
    `; } // ─── секция планировки (таблица комнат из заметки) ─────── 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 `
    ${esc(plan.eyebrow)}

    ${esc(plan.nav)}

    ${esc(plan.lead)}

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

    ${h.title}

    ${esc(h.lead)}

    ${materials}
    ${stats}
    Планировка Планировка квартиры
    ${renderPlan(cfg.plan)} ${sections} `; } // ─── оптимизация картинок ───────────────────────────────── 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); });