import * as fs from "node:fs/promises"; import * as cheerio from "cheerio"; import { matter } from "toml-matter"; import { Marked } from "marked"; import log from "loglevel"; import config from "./config"; import { getPost, readPosts, type Post } from "./posts"; const marked = new Marked(); marked.use({ gfm: true, }); function addMenu( parent: cheerio.Cheerio, child: cheerio.Cheerio, ) { parent.empty(); for (const link of config.menus.main) { parent.append(child.clone().attr("href", link.url).text(link.name)); } } export async function layout( $: cheerio.CheerioAPI, pageTitle: string, ): Promise { $("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(await Bun.file("templates/style.css").text()); return $; } async function render404Page(): Promise { const $ = await layout( cheerio.load(await Bun.file("templates/404.html").text()), "404 Not Found", ); return $.html(); } async function renderHomepage(posts: Array): Promise { const file = matter(await Bun.file("content/_index.md").text()); const $ = await layout( cheerio.load(await Bun.file("templates/homepage.html").text()), 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()) .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( cheerio.load(await Bun.file("templates/post.html").text()), file.title, ); $(".title").addClass("h-card p-author").attr("rel", "author"); $(".h-entry .dt-published") .attr("datetime", file.date.toISOString()) .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) { 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( cheerio.load(await Bun.file("templates/list.html").text()), tag || config.title, ); const $feed = $(".h-feed"); const $tpl = $(".h-entry").remove(); $(".title").addClass("p-author h-card").attr("rel", "author"); 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()) .text(post.date.toISOString().slice(0, 10)); $post.appendTo($feed); } return $.html(); } async function renderTags(tags: string[]) { const $ = await layout( cheerio.load(await Bun.file("templates/tags.html").text()), 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 Bun.file("templates/feed.xml").text(), { 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()); 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()); $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(Bun.escapeHTML(await content)); $post.appendTo($feed); } return $.xml(); } async function renderFeedStyles() { const $ = cheerio.load(await Bun.file("templates/feed-styles.xsl").text(), { xml: true, }); $("style").text(await Bun.file("templates/style.css").text()); 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 Bun.write(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 Bun.write("public/tags/index.html", await renderTags([...tags])); }); 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 Bun.write( `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 Bun.write( `public/tags/${tag}/atom.xml`, await renderFeed(`${config.title} - ${tag}`, matchingPosts), ); }); } tasks.push(async () => { log.debug("Rendering posts page to public/post/index.html"); return Bun.write("public/post/index.html", await renderListPage("", posts)); }); tasks.push(async () => { log.debug("Rendering site feed to public/atom.xml"); return Bun.write("public/atom.xml", await renderFeed(config.title, posts)); }); tasks.push(async () => { log.debug("Rendering feed styles to public/feed-styles.xsl"); return Bun.write("public/feed-styles.xsl", await renderFeedStyles()); }); tasks.push(async () => { log.debug("Rendering homepage to public/index.html"); return Bun.write( "public/index.html", await renderHomepage(posts.slice(0, 3)), ); }); tasks.push(async () => { log.debug("Rendering 404 page to public/404.html"); return Bun.write("public/404.html", await render404Page()); }); for (const task of tasks) { await task(); } }