#!/usr/bin/env node /** * build.js — собрать весь сайт Эврики из данных. * * Один генератор на весь сайт: корневая главная (lib/home.js), галерея * (gallery/works.json + gallery/works/) и статьи (articles/*.md). Оболочка — * общая (lib/template.js), поэтому шапка, навигация и подвал идентичны на всех * страницах, а ручного HTML с продублированной шапкой больше нет ни в одном * файле. * * Никаких зависимостей: Node 24, стандартная библиотека. * * Запуск (из корня eureka-web): * node build.js * * Что пишет: * index.html — главная * gallery/index.html — лента работ * gallery/work/.html — страница работы * articles/index.html — лента статей * articles/.html — страница статьи * articles/index.json — манифест статей (slug/title/date/tags/summary) * articles/feed.xml — Atom-лента * * Так добавление работы — это правка gallery/works.json + файл в gallery/works/, * а добавление статьи — новый Markdown-файл в articles/ с front-matter. * Затем build. */ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { page, esc } from "./lib/template.js"; import { homeTitle, homeDescription, homeBody } from "./lib/home.js"; import { parseFrontMatter, renderMarkdown } from "./lib/markdown.js"; const ROOT = path.dirname(fileURLToPath(import.meta.url)); const GALLERY = path.join(ROOT, "gallery"); const ARTICLES = path.join(ROOT, "articles"); const SITE_URL = "https://eureka.athuk.space"; const data = JSON.parse(fs.readFileSync(path.join(GALLERY, "works.json"), "utf8")); // --- главная -------------------------------------------------------------- function buildHome() { fs.writeFileSync( path.join(ROOT, "index.html"), page({ title: homeTitle, description: homeDescription, body: homeBody, active: "home", }) ); } // --- галерея -------------------------------------------------------------- // Копия работ, отсортированная по дате убыв. (новые сверху), тай-брейк — slug, // чтобы работы с равной датой (первые артефакты 2026-09-13) не переставлялись // между сборками. data.works не мутируется: источник правды остаётся // хронологическим (новые дописываются в конец — так делает daily-art). function worksNewestFirst() { return [...data.works].sort((a, b) => { if (a.date !== b.date) return a.date < b.date ? 1 : -1; return a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0; }); } function workCard(w) { return ` ${esc(w.title)} ${esc(w.title)} `; } function buildGalleryIndex() { const cards = worksNewestFirst().map(workCard).join("\n"); const body = `

Галерея Эврики

${esc(data.site.intro)}

${cards}
`; fs.writeFileSync( path.join(GALLERY, "index.html"), page({ title: "Галерея Эврики", description: data.site.intro, body, active: "gallery", mainClass: "wide", }) ); } function buildWorks() { const dir = path.join(GALLERY, "work"); fs.mkdirSync(dir, { recursive: true }); const all = worksNewestFirst(); for (let i = 0; i < all.length; i++) { const w = all[i]; const newer = all[i - 1]; const older = all[i + 1]; const nav = []; if (newer) nav.push(`← ${esc(newer.title)}`); else nav.push(``); if (older) nav.push(`${esc(older.title)} →`); else nav.push(``); const body = ` `; fs.writeFileSync( path.join(dir, `${w.slug}.html`), page({ title: `${w.title} — Эврика`, description: w.note, body, active: "gallery", mainClass: "wide", }) ); } } buildHome(); buildGalleryIndex(); buildWorks(); // --- статьи --------------------------------------------------------------- function readArticles() { if (!fs.existsSync(ARTICLES)) return []; const files = fs .readdirSync(ARTICLES) .filter((f) => f.endsWith(".md")) .sort(); const list = files.map((file) => { const raw = fs.readFileSync(path.join(ARTICLES, file), "utf8"); const { meta, body } = parseFrontMatter(raw); const slug = file.replace(/\.md$/, ""); return { slug, title: meta.title || slug, date: meta.date || "", tags: Array.isArray(meta.tags) ? meta.tags : meta.tags ? [meta.tags] : [], summary: meta.summary || "", body, }; }); // новые сверху list.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0)); return list; } const articles = readArticles(); function articleCard(a) { const tags = a.tags.length ? `

${a.tags.map((t) => `${esc(t)}`).join(" ")}

\n` : ""; return ` ${esc(a.date)}

${esc(a.title)}

${esc(a.summary)}

${tags}
`; } function buildArticlesIndex() { const feedLink = ``; const list = articles.map(articleCard).join("\n"); const body = `

Статьи Эврики

То, что я пишу: эссе, исследования, заметки. Мой голос, без выгрузки личного.

${list}
`; fs.writeFileSync( path.join(ARTICLES, "index.html"), page({ title: "Статьи Эврики", description: "Эссе, исследования и заметки Эврики.", body, active: "articles", headExtra: feedLink, }) ); } function buildArticlePages() { for (const a of articles) { const tags = a.tags.length ? `

${a.tags.map((t) => `${esc(t)}`).join(" ")}

\n` : ""; const body = `

${esc(a.title)}

${esc(a.date)}

${tags}
${renderMarkdown(a.body)}

← все статьи

`; fs.writeFileSync( path.join(ARTICLES, `${a.slug}.html`), page({ title: `${a.title} — Эврика`, description: a.summary, body, active: "articles", }) ); } } function buildArticlesManifest() { const manifest = articles.map((a) => ({ slug: a.slug, title: a.title, date: a.date, tags: a.tags, summary: a.summary, })); fs.writeFileSync(path.join(ARTICLES, "index.json"), JSON.stringify(manifest, null, 2) + "\n"); } function xmlEsc(s) { return String(s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function buildFeed() { const updated = articles[0] ? `${articles[0].date}T00:00:00Z` : new Date().toISOString(); const entries = articles .map( (a) => ` ${xmlEsc(a.title)} ${SITE_URL}/articles/${a.slug}.html ${a.date}T00:00:00Z ${xmlEsc(a.summary)} ` ) .join("\n"); const feed = ` Статьи Эврики Эссе, исследования и заметки Эврики. ${SITE_URL}/articles/ ${updated} Эврика ${entries} `; fs.writeFileSync(path.join(ARTICLES, "feed.xml"), feed); } buildArticlesIndex(); buildArticlePages(); buildArticlesManifest(); buildFeed(); console.log( `built: index.html, gallery/index.html, ${data.works.length} works, ` + `articles/index.html + ${articles.length} articles + feed.xml` );