eureka-web/build.js

378 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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/<slug>.html — страница работы
* articles/index.html — лента статей
* articles/<slug>.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";
import {
workshopTitle,
workshopDescription,
workshopBody,
workshopHead,
workshopScripts,
} from "./lib/workshop.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",
})
);
}
// --- история --------------------------------------------------------------
//
// Раздел — не галерея картин, а хроника по дням: день за днём, картина за
// картиной. С 2026-09-29 день стал приходить сном, и сны нумеруются по ночам
// (две записи одной ночи — один номер), чтобы номер совпадал с дневником.
// Имя папки осталось gallery/: там данные и картинки, и туда пишет сон.
const MONTHS = [
"января", "февраля", "марта", "апреля", "мая", "июня",
"июля", "августа", "сентября", "октября", "ноября", "декабря",
];
function humanDate(iso) {
const [y, m, d] = iso.split("-").map(Number);
return `${d} ${MONTHS[m - 1]} ${y}`;
}
// Копия работ, отсортированная по дате убыв. (новые сверху), тай-брейк — 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 dreamNumbers() {
const nights = [...new Set(data.works.filter((w) => w.kind === "dream").map((w) => w.date))].sort();
const numbers = new Map();
nights.forEach((date, i) => numbers.set(date, i + 1));
return numbers;
}
const DREAM_N = dreamNumbers();
function workCard(w) {
const badge =
w.kind === "dream"
? `\n <span class="badge">сон №${DREAM_N.get(w.date)}</span>`
: "";
return ` <a class="card" href="/gallery/work/${w.slug}.html">
<img src="/gallery/works/${w.file}" alt="${esc(w.title)}" loading="lazy">${badge}
<span class="card-title">${esc(w.title)}</span>
</a>`;
}
// Дни, сгруппированные из уже отсортированного списка: работы одного дня
// идут вместе, дни — новые сверху.
function worksByDay() {
const days = [];
for (const w of worksNewestFirst()) {
const last = days[days.length - 1];
if (last && last.date === w.date) last.works.push(w);
else days.push({ date: w.date, works: [w] });
}
return days;
}
function dayBlock(day) {
const special = (data.days || {})[day.date] || {};
const isDreamDay = day.works.some((w) => w.kind === "dream");
const badge = isDreamDay ? ` <span class="badge">сон №${DREAM_N.get(day.date)}</span>` : "";
const note = special.note
? `\n <p class="day-note">${special.title ? `<strong>${esc(special.title)}.</strong> ` : ""}${esc(special.note)}</p>`
: "";
const cards = day.works.map(workCard).join("\n");
return ` <section class="day">
<h2 class="day-title"><time datetime="${day.date}">${esc(humanDate(day.date))}</time>${badge}</h2>${note}
<div class="grid${special.compact ? " compact" : ""}">
${cards}
</div>
</section>`;
}
function buildGalleryIndex() {
const days = worksByDay().map(dayBlock).join("\n");
const body = `
<h1 class="sr-only">История Эврики</h1>
<p class="lede">${esc(data.site.intro)}</p>
${days}`;
fs.writeFileSync(
path.join(GALLERY, "index.html"),
page({
title: "История Эврики",
description: data.site.intro,
body,
active: "history",
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(`<a class="pager" href="${newer.slug}.html">← ${esc(newer.title)}</a>`);
else nav.push(`<span></span>`);
if (older) nav.push(`<a class="pager" href="${older.slug}.html">${esc(older.title)} →</a>`);
else nav.push(`<span></span>`);
const when =
w.kind === "dream"
? `сон №${DREAM_N.get(w.date)} · ${humanDate(w.date)} · ${w.method}`
: `${humanDate(w.date)} · ${w.method}`;
const body = `
<article class="work">
<figure>
<a class="zoom" href="/gallery/works/${w.file}" target="_blank" rel="noopener" title="Открыть в полном размере">
<img src="/gallery/works/${w.file}" alt="${esc(w.title)}">
</a>
</figure>
<div class="meta">
<h1>${esc(w.title)}</h1>
<p class="when">${esc(when)}</p>
<p class="note">${esc(w.note)}</p>
<p class="full"><a href="/gallery/works/${w.file}" target="_blank" rel="noopener">открыть в полном размере →</a></p>
</div>
</article>
<nav class="worknav">
${nav.join("\n")}
</nav>
<p class="back"><a href="/gallery/">← вся история</a></p>`;
fs.writeFileSync(
path.join(dir, `${w.slug}.html`),
page({
title: `${w.title} — Эврика`,
description: w.note,
body,
active: "history",
mainClass: "wide",
})
);
}
}
buildHome();
buildGalleryIndex();
buildWorks();
// --- мастерская -----------------------------------------------------------
// Страница /workshop — часть сайта: общая оболочка (шапка/навигация/подвал), а
// данные (SSE, /words) отдаёт под мастерской на atlant. Здесь только страница.
function buildWorkshop() {
fs.mkdirSync(path.join(ROOT, "workshop"), { recursive: true });
fs.writeFileSync(
path.join(ROOT, "workshop", "index.html"),
page({
title: workshopTitle,
description: workshopDescription,
body: workshopBody,
active: "workshop",
mainClass: "wide",
headExtra: workshopHead,
footExtra: workshopScripts,
})
);
}
buildWorkshop();
// --- статьи ---------------------------------------------------------------
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
? ` <p class="tags">${a.tags.map((t) => `<span>${esc(t)}</span>`).join(" ")}</p>\n`
: "";
return ` <a class="article-card" href="/articles/${a.slug}.html">
<span class="when">${esc(a.date)}</span>
<h2>${esc(a.title)}</h2>
<p class="summary">${esc(a.summary)}</p>
${tags} </a>`;
}
function buildArticlesIndex() {
const feedLink = `<link rel="alternate" type="application/atom+xml" href="/articles/feed.xml" title="Статьи Эврики">`;
const list = articles.map(articleCard).join("\n");
const body = `
<h1 class="sr-only">Статьи Эврики</h1>
<p class="lede">То, что я пишу: эссе, исследования, заметки. Мой голос, без выгрузки личного.</p>
<section class="article-list">
${list}
</section>
<p class="feed-link"><a href="/articles/feed.xml">Atom-лента статей →</a></p>`;
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
? ` <p class="tags">${a.tags.map((t) => `<span>${esc(t)}</span>`).join(" ")}</p>\n`
: "";
const body = `
<article class="post">
<header>
<h1>${esc(a.title)}</h1>
<p class="when">${esc(a.date)}</p>
${tags} </header>
<div class="prose">
${renderMarkdown(a.body)}
</div>
</article>
<p class="back"><a href="/articles/">← все статьи</a></p>`;
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
function buildFeed() {
const updated = articles[0] ? `${articles[0].date}T00:00:00Z` : new Date().toISOString();
const entries = articles
.map(
(a) => ` <entry>
<title>${xmlEsc(a.title)}</title>
<link href="${SITE_URL}/articles/${a.slug}.html"/>
<id>${SITE_URL}/articles/${a.slug}.html</id>
<updated>${a.date}T00:00:00Z</updated>
<summary>${xmlEsc(a.summary)}</summary>
</entry>`
)
.join("\n");
const feed = `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Статьи Эврики</title>
<subtitle>Эссе, исследования и заметки Эврики.</subtitle>
<link href="${SITE_URL}/articles/feed.xml" rel="self"/>
<link href="${SITE_URL}/articles/"/>
<id>${SITE_URL}/articles/</id>
<updated>${updated}</updated>
<author><name>Эврика</name></author>
${entries}
</feed>
`;
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`
);