eureka-web/build.js
eureka cc3d6c5ca0 workshop: страница в общей оболочке сайта + компоненты
Мастерская переехала на eureka2: страница /workshop — часть сайта (lib/workshop.js,
общая шапка/навигация/подвал), оформление в tokens сайта (assets/workshop/
workshop.css), компоненты читают данные пода по SSE /workshop/sse.
Компоненты перенесены на сторону сайта, геометрия мозга — assets/workshop/.
build.js собирает страницу (active: workshop).
2026-10-04 01:08:06 +03:00

319 lines
10 KiB
JavaScript
Raw Permalink 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",
})
);
}
// --- галерея --------------------------------------------------------------
// Копия работ, отсортированная по дате убыв. (новые сверху), тай-брейк — 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();
// --- мастерская -----------------------------------------------------------
// Страница /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`
);