buildBlog

tcmts.dev.buildBlog · #uigpy7tz · function · runs on the server

uses node:fs

Build tcm's blog into out, in the docs site's look: a page for each post (#fp3gzlcf), with who wrote it, when, and the posts before and after it; the list of them (index.html); and its RSS feed (rss.xml, blogFeed), which every page names for feed readers to find. Each post's page names its standard.site record (#yxevay4p). Each page says what it is for search engines and link previews. tcm's server has it at /blog (pnpm server:deploy). A post's link to a docs page that isn't one is a problem.

import {
  BLOG_DESCRIPTION, BLOG_URL, blogFeed, esc, finishSite, highlighter, pageList, postDay, POSTS, renderPost, SOURCES,
  STANDARD_SITE, STYLE,
} from "#codebase/tcmts/site";
import type { Post } from "#codebase/tcmts/site";
import fs from "node:fs";
import path from "node:path";

export async function buildBlog(out: string): Promise<{ posts: number; problems: string[] }> {
  const hl = await highlighter();
  const docs = new Set(pageList(SOURCES["docs/README.md"]!).map((p) => p.file));
  const problems: string[] = [];
  const page = (
    meta: { title: string; name: string; description: string; url: string; post?: Post },
    body: string,
  ) => {
    const { title, name, description, url, post } = meta;
    const head = [
      `<title>${esc(title)}</title>`,
      `<meta name="description" content="${esc(description)}">`,
      `<link rel="canonical" href="${url}">`,
      `<link rel="alternate" type="application/rss+xml" title="tcm blog" href="rss.xml">`,
      `<meta property="og:site_name" content="tcm">`,
      `<meta property="og:type" content="${post ? "article" : "website"}">`,
      `<meta property="og:title" content="${esc(name)}">`,
      `<meta property="og:description" content="${esc(description)}">`,
      `<meta property="og:url" content="${url}">`,
      ...(post
        ? [
            `<meta property="article:published_time" content="${post.date}">`,
            `<meta name="author" content="${esc(post.author)}">`,
            // (its standard.site record, which readers on the AT Protocol check it's the page of)
            STANDARD_SITE.link(post.slug),
          ]
        : []),
      `<meta name="twitter:card" content="summary">`,
    ].join("\n");
    return `<!doctype html>\n<html lang="en"><head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n${head}\n<link rel="stylesheet" href="style.css">\n</head><body>\n<header class="top"><div class="bar"><a class="logo" href="/"><span class="mark"></span>tcm</a><nav class="links" aria-label="site"><a href="/docs/">Docs</a><a href="/docs/examples.html">Examples</a><a class="on" href="./">Blog</a><a href="/forge/">Forge</a><a href="/registry/">Registry</a></nav></div></header>\n<div class="site single blog">\n<main>\n${body}\n</main>\n</div>\n</body></html>\n`;
  };
  fs.rmSync(out, { recursive: true, force: true });
  fs.mkdirSync(out, { recursive: true });
  fs.writeFileSync(path.join(out, "style.css"), STYLE);
  const posts = POSTS.map((post) => ({ post, rendered: renderPost(post, hl, docs) }));
  const day = (post: Post) => `<time datetime="${post.date}">${postDay(post.date)}</time>`;
  posts.forEach(({ post, rendered: r }, i) => {
    problems.push(...r.problems.map((p) => `blog/${post.slug}: ${p}`));
    // (newest first: the one before it in the list is newer)
    const newer = posts[i - 1];
    const older = posts[i + 1];
    const pager =
      newer || older
        ? `<nav class="pager" aria-label="newer and older posts">${
            older
              ? `<a class="prev" href="${older.post.slug}.html"><small>Older</small>${esc(older.rendered.title)}</a>`
              : "<span></span>"
          }${
            newer
              ? `<a class="next" href="${newer.post.slug}.html"><small>Newer</small>${esc(newer.rendered.title)}</a>`
              : ""
          }</nav>\n`
        : "";
    fs.writeFileSync(
      path.join(out, `${post.slug}.html`),
      page(
        {
          title: `${r.title} — tcm`,
          name: r.title,
          description: r.description,
          url: `${BLOG_URL}${post.slug}.html`,
          post,
        },
        `<article>\n<h1>${r.titleHtml}</h1>\n<p class="date">${day(post)} · ${esc(post.author)}</p>\n${r.body}</article>\n${pager}<p class="back"><a href="./">All posts</a> · <a href="rss.xml">RSS feed</a></p>`,
      ),
    );
  });
  const list = posts.map(
    ({ post, rendered: r }) =>
      `<article><p class="date">${day(post)}</p><h2><a href="${post.slug}.html">${esc(r.title)}</a></h2><p>${r.summary}</p></article>`,
  );
  fs.writeFileSync(
    path.join(out, "index.html"),
    page(
      { title: "Blog — tcm", name: "tcm blog", description: BLOG_DESCRIPTION, url: BLOG_URL },
      `<h1>Blog</h1>\n<p class="lede">${esc(BLOG_DESCRIPTION)} <a href="rss.xml">RSS feed</a></p>\n<div class="posts">\n${list.join("\n")}\n</div>`,
    ),
  );
  fs.writeFileSync(path.join(out, "rss.xml"), blogFeed(posts));
  finishSite(out);
  return { posts: POSTS.length, problems };
}

uses

used by