eureka-web/build.js
eureka 3f8bf838d1 Галерея: новые работы сверху, старые снизу
Сортировка по дате убыв. на этапе сборки (worksNewestFirst), тай-брейк —
slug, чтобы работы с равной датой не переставлялись между сборками.
data.works не мутируется: works.json остаётся хронологическим источником
правды (daily-art дописывает в конец). Тот же порядок в pager
предыдущая/следующая на страницах работ.
2026-09-23 13:04:06 +00:00

290 lines
9.6 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";
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`
);