import * as fs from "node:fs/promises"; import * as cheerio from "cheerio"; import { matter } from "toml-matter"; import { Marked } from "marked"; import { PurgeCSS } from "purgecss"; import log from "loglevel"; import config from "./config"; import { getPost, readPosts, type Post } from "./posts"; const css = await Bun.file("templates/style.css").text(); const marked = new Marked(); marked.use({ gfm: true, }); const purgeCSS = new PurgeCSS(); function addMenu( parent: cheerio.Cheerio<cheerio.AnyNode>, child: cheerio.Cheerio<cheerio.AnyNode>, ) { parent.empty(); for (const link of config.menus.main) { parent.append(child.clone().attr("href", link.url).text(link.name)); } } export async function layout( html: string, pageTitle: string, ): Promise<cheerio.CheerioAPI> { const ccss = ( await purgeCSS.purge({ content: [ { raw: html, extension: ".html", }, ], css: [{ raw: css }], }) )[0].css; const $ = cheerio.load(html); $("html").attr("lang", config.default_language); $("head > link[rel=alternate]").attr("title", config.title); addMenu($("nav"), $("nav a")); $(".title").text(config.title); $("title").text(pageTitle); $(".p-name").text(pageTitle); $("head").children("style").text(ccss); return $; } async function render404Page(): Promise<string> { const $ = await layout( await fs.readFile("templates/404.html", "utf-8"), "404 Not Found", ); return $.html(); } async function renderHomepage(posts: Array<Post>): Promise<string> { const file = matter(await fs.readFile("content/_index.md", "utf-8")); const $ = await layout( await fs.readFile("templates/homepage.html", "utf-8"), config.title, ); $("body").addClass("h-card"); $(".title").addClass("p-name").addClass("u-url"); $("#content").html(await marked.parse(file.content)); const $feed = $(".h-feed"); const $entry = $(".h-entry").remove(); for (const post of posts) { const $post = $entry.clone(); $post.find(".p-name").text(post.title); $post.find(".u-url").attr("href", post.url); $post .find(".dt-published") .attr("datetime", post.date.toISOString().replace(/\.\d{3}/, "")) .text(post.date.toISOString().slice(0, 10)); $post.appendTo($feed); } $(".u-email").attr("href", `mailto:${config.email}`).text(config.email); const $elsewhere = $(".elsewhere"); const $linkRelMe = $elsewhere.find(".u-url[rel=me]").parentsUntil("ul"); $linkRelMe.remove(); for (const link of config.menus.me) { const $link = $linkRelMe.clone(); $link.find("a").attr("href", link.url).text(link.name); $link.appendTo($elsewhere); } return $.html(); } async function renderPost(file: Post, content: string) { const $ = await layout( await fs.readFile("templates/post.html", "utf-8"), file.title, ); $(".title").addClass("h-card p-author").attr("rel", "author"); $(".h-entry .dt-published") .attr("datetime", file.date.toISOString().replace(/\.\d{3}/, "")) .text(file.date.toISOString().slice(0, 10)); $(".h-entry .e-content").html(content); const categories = $(".h-entry .p-categories"); const cat = categories.find(".p-category").parentsUntil(categories); cat.remove(); for (const tag of file.taxonomies["tags"].sort()) { categories.append( cat .clone() .find(".p-category") .attr("href", `/tags/${tag}/`) .text(`#${tag}`) .parent(), ); } return $.html(); } async function renderListPage(tag: string, posts: Post[]) { const $ = await layout( await fs.readFile("templates/list.html", "utf-8"), tag || config.title, ); const $feed = $(".h-feed"); const $tpl = $(".h-entry").remove(); $(".title").addClass("p-author h-card").attr("rel", "author"); if (tag === "") { $(".filter").remove(); } else { $(".filter").find("h3").text(`#${tag}`); } for (const post of posts) { const $post = $tpl.clone(); $post.find(".p-name").text(post.title); $post.find(".u-url").attr("href", post.url); $post .find(".dt-published") .attr("datetime", post.date.toISOString().replace(/\.\d{3}/, "")) .text(post.date.toISOString().slice(0, 10)); $post.appendTo($feed); } return $.html(); } async function renderTags(tags: string[]) { const $ = await layout( await fs.readFile("templates/tags.html", "utf-8"), config.title, ); const $tags = $(".tags"); const $tpl = $(".h-feed"); $tpl.remove(); for (const tag of tags) { const $tag = $tpl.clone(); $tag.find("a").attr("href", `/tags/${tag}/`).text(`#${tag}`); $tag.appendTo($tags); } return $.html(); } const makeTagURI = (specific: string) => `tag:${config.original_domain},${config.domain_start_date}:${specific}`; async function renderFeed(title: string, posts: Post[], tag?: string) { const $ = cheerio.load(await fs.readFile("templates/feed.xml", "utf-8"), { xml: true, }); const $feed = $("feed"); $feed.children("title").text(title); $feed.children("link").attr("href", config.base_url); $feed.children("id").text(makeTagURI(tag || "feed")); $feed .children("updated") .text(posts[0].date.toISOString().replace(/\.\d{3}/, "")); const $tpl = $("feed > entry").remove(); for (const post of posts) { const $post = $tpl.clone(); $post.children("title").text(post.title); $post .children("link") .attr("href", new URL(post.url, config.base_url).href); $post.children("id").text(makeTagURI(post.basename)); $post .children("updated") .text(post.date.toISOString().replace(/\.\d{3}/, "")); $post.find("author > name").text(config.title); $post.children("summary").text(post.description || ""); const content = marked.parse((await getPost(post.input)).content); $post.children("content").html(await content); $post.appendTo($feed); } return $.xml(); } async function renderFeedStyles() { const $ = cheerio.load( await fs.readFile("templates/feed-styles.xsl", "utf-8"), { xml: true, }, ); $("style").text(css); return $.xml(); } export default async function generateSite() { const tasks = []; const { posts, tags } = await readPosts("content", "post", "public"); await fs.mkdir("public/post", { recursive: true }); for (const post of posts) { const content = await marked.parse((await getPost(post.input)).content); await fs.mkdir(`public/post/${post.basename}`, { recursive: true }); tasks.push(async () => { log.debug(`Rendering post ${post.basename} to ${post.output}`); return fs.writeFile(post.output, await renderPost(post, content)); }); } await fs.mkdir("public/tags", { recursive: true }); tasks.push(async () => { log.debug("Rendering tags page to public/tags/index.html"); return fs.writeFile( "public/tags/index.html", await renderTags([...tags].sort()), ); }); for (const tag of tags) { log.debug(`Processing tag ${tag}`); const matchingPosts = posts.filter((p) => p.taxonomies["tags"].includes(tag), ); await fs.mkdir(`public/tags/${tag}`, { recursive: true }); tasks.push(async () => { log.debug(`Rendering tag ${tag} to public/tags/${tag}/index.html`); return fs.writeFile( `public/tags/${tag}/index.html`, await renderListPage(tag, matchingPosts), ); }); tasks.push(async () => { log.debug(`Rendering tag ${tag} feed to public/tags/${tag}/atom.xml`); return fs.writeFile( `public/tags/${tag}/atom.xml`, await renderFeed(`${config.title} - ${tag}`, matchingPosts, tag), ); }); } tasks.push(async () => { log.debug("Rendering posts page to public/post/index.html"); return fs.writeFile( "public/post/index.html", await renderListPage("", posts), ); }); tasks.push(async () => { log.debug("Rendering site feed to public/atom.xml"); return fs.writeFile( "public/atom.xml", await renderFeed(config.title, posts), ); }); tasks.push(async () => { log.debug("Rendering feed styles to public/feed-styles.xsl"); return fs.writeFile("public/feed-styles.xsl", await renderFeedStyles()); }); tasks.push(async () => { log.debug("Rendering homepage to public/index.html"); return fs.writeFile( "public/index.html", await renderHomepage(posts.slice(0, 3)), ); }); tasks.push(async () => { log.debug("Rendering 404 page to public/404.html"); return fs.writeFile("public/404.html", await render404Page()); }); return Promise.all(tasks.map((f) => f())); }