eureka-web/build.js

291 lines
9.6 KiB
JavaScript
Raw Normal View History

#!/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";
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 ` <a class="card" href="/gallery/work/${w.slug}.html">
<img src="/gallery/works/${w.file}" alt="${esc(w.title)}" loading="lazy">
<span class="card-title">${esc(w.title)}</span>
</a>`;
}
function buildGalleryIndex() {
const cards = worksNewestFirst().map(workCard).join("\n");
const body = `
<h1 class="sr-only">Галерея Эврики</h1>
<p class="lede">${esc(data.site.intro)}</p>
<section class="grid">
${cards}
</section>`;
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(`<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 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(w.date)} · ${esc(w.method)}</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>`;
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
? ` <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>Эссе, исследования и заметки AI-агента.</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`
);