@@ -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 ( /(?<!\\)\|/ ) . map ( ( c ) => 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
? ` <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
? ` <span class="opt__price"> ${ esc ( priceNum ) } ${ unit ? ` <span> ${ unit } </span> ` : '' } </span> ` : '' ;
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">
<span class="opt__no">Вариант ${ esc ( no ) } </span>
<span class="opt__name"> ${ inline ( name ) } </span>
@@ -175,9 +211,7 @@ function tableCell(c) {
}
function renderTable ( tbl ) {
const head = tbl . headers . map ( ( h ) => ` <th> ${ inline ( h ) } </th> ` ) . join ( '' ) ;
const body = tbl . rows . map ( ( r ) =>
` <tr> ${ r . map ( ( c ) => ` <td> ${ tableCell ( c ) } </td> ` ) . join ( '' ) } </tr> `
) . join ( '\n' ) ;
const body = tbl . rows . map ( ( r ) => ` <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> ` ;
}
@@ -185,59 +219,37 @@ function renderImages(imgs) {
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> ` ;
}
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 ( '' ) ;
return ` <div class="renders ${ cls } reveal"> ${ cells } </div> ` ;
}
function renderV ariants( items ) {
const li = items . map ( ( it ) => {
function v ariantItem s( items ) {
return 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 ` <ol class="variants reveal"> ${ li } </ol> ` ;
}
// картинка сразу перед списком → блок «отделки» (image | варианты)
function renderVariants ( items ) { return ` <ol class="variants reveal"> ${ variantItems ( items ) } </ol> ` ; }
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">
<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 ) {
const c = count != null
? ` <span class="count"> ${ count } ${ plural ( count , 'вариант' , 'варианта' , 'вариантов' ) } </span> ` : '' ;
return ` <div class="subhead reveal"><h3> ${ inline ( text ) } </h3> ${ c } </div> ` ;
}
// ─── сборка секции ────────────────────────────────────────
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 ( ` <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 ) } ">
<div class="wrap">
<div class="section__head reveal">
<div>
<span class="eyebrow"> ${ esc ( sec . eyebrow || '' ) } </span>
<h2> ${ esc ( title ) } </h2>
${ lead ? ` <p class="section__lead"> ${ inline ( lead ) } </p> ` : '' }
<span class="eyebrow"> ${ esc ( sec . eyebrow ) } </span>
<h2> ${ esc ( sec . title) } </h2>
${ sec . lead ? ` <p class="section__lead"> ${ inline ( sec . lead) } </p> ` : '' }
${ sw ? ` <div class="swatches"> ${ sw } </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>
${ out . join ( '\n ' ) }
${ bodyHtml }
</div>
</section> ` ;
}
// ─── секция планировки (таблица комнат из заметки) ───────
function renderPlan ( plan ) {
const md = fs . readFileSync ( path . join ( ROOT , plan . file ) , 'utf8' ) ;
const block s = 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 = ` <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> ` ;
function renderPlan ( rooms , sections ) {
const byRoom = new Map ( ) ;
sections . forEach ( ( s ) => { if ( s . room ) byRoom . set ( s . room . no , s ) ; } ) ;
const item s = rooms . map ( ( r ) => {
const sec = byRoom . get ( r . no ) ;
const nameHtml = ` <span class="room-name"> ${ esc ( r . name ) } ${ r . sub || ( sec && sec . sub ) ? ` <small> ${ esc ( r . sub || sec . sub ) } </small> ` : '' } </span> ` ;
const nameCell = sec ? ` <a class="rooms-link" href="# ${ esc ( sec . id ) } "> ${ nameHtml } </a> ` : nameHtml ;
const tag = sec ? ( sec . hasContent
? '<span class="tag tag--done">концепт готов</span>'
: '<span class="tag tag--wip">в работе</span>' ) : '' ;
return ` <li><span class="room-no"> ${ esc ( r . no ) } </span> ${ nameCell } ${ tag } <span class="room-area"> ${ esc ( r . area ) } </span></li> ` ;
} ) . join ( '\n' ) ;
}
return ` <section class="section" id=" ${ esc ( plan . id ) } ">
return ` <section class="section" id=" ${ esc ( cfg . plan . id ) } ">
<div class="wrap">
<div class="section__head reveal">
<div>
<span class="eyebrow"> ${ esc ( plan . eyebrow ) } </span>
<h2> ${ esc ( plan . nav ) } </h2>
<p class="section__lead"> ${ esc ( plan . lead ) } </p>
<span class="eyebrow"> ${ esc ( cfg . plan. eyebrow ) } </span>
<h2> ${ esc ( cfg . plan. nav ) } </h2>
<p class="section__lead"> ${ esc ( cfg . plan. lead ) } </p>
</div>
<div class="section__index" aria-hidden="true">${ esc ( plan . index ) } </div>
<div class="section__index" aria-hidden="true">01 </div>
</div>
<div class="plan-layout">
<figure class="plan-figure reveal"><img src=" ${ imgSrc ( cfg . hero . planImage ) } " alt="Схема планировки" data-zoom></figure>
<ul class="rooms reveal"> ${ roo ms} </ul>
<ul class="rooms reveal"> ${ ite ms} </ul>
</div>
</div>
</section> ` ;
}
// ─── страница целиком ─────────────────────────────────────
function buildHtml ( ) {
function buildHtml ( rooms , sections ) {
const h = cfg . hero ;
const nav = [ cfg . plan , ... cfg . sections ] . 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 ' ) ;
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 ) =>
` <div><span class="num"> ${ esc ( s . num ) } </span><span class="lbl"> ${ esc ( s . lbl ) } </span></div> ` ) . 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 ) => ` <a href="# ${ s . id } "><span class="lg"> ${ esc ( s . nav ) } </span><span class="sm"> ${ esc ( s . navShort || s . nav ) } </span></a> ` ) . 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 num = /концепт/i . t est ( s . lbl ) ? String ( doneCount ) : s . num ;
return ` <div><span class="num"> ${ esc ( num ) } </span><span class="lbl"> ${ esc ( s . lbl ) } </span></div> ` ;
} ) . join ( '\n ' ) ;
const sectionsHtml = sections . map ( renderSection ) . join ( '\n\n' ) ;
return ` <!DOCTYPE html>
<html lang="ru" data-theme="light">
@@ -387,9 +433,9 @@ function buildHtml() {
</div>
</section>
${ renderPlan ( cfg . plan ) }
${ renderPlan ( rooms , sections ) }
${ sections }
${ sectionsHtml }
<footer class="footer">
<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 , '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 . 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 ( ` картинок : ${ u sedImages . size } , секций: ${ cfg . se ctions. 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 ) ; } ) ;