Commit c061264

HPCesia <me@hpcesia.com>
2026-03-11 12:24:13
Atom feed support
1 parent d0a47b1
src/components/Footer.astro
@@ -10,6 +10,7 @@ import { SITE_CONFIG } from "@/config";
     <span>{SITE_CONFIG.author}</span>
   </div>
   <div>
+    <a href="/feed.atom">Feed</a>
     <a href="https://codeberg.org/HPCesia/Tybios" target="_blank">Codeberg</a>
   </div>
 </footer>
src/contents/_lib/templates/footnote.typ
@@ -1,7 +1,7 @@
 #import "@preview/bullseye:0.1.0": show-target
 
 #let side-footnote-html(body) = {
-  show footnote: it => {
+  show footnote: it => if (sys.inputs.at("x-renderer", default: "astro-typst") == "tybios") { it } else {
     let number = counter(footnote).display(it.numbering)
     let fn-id = "fn-" + number
     let ref-id = "fnref-" + number
src/contents/_lib/templates/raw.typ
@@ -3,7 +3,10 @@
 #import "@preview/zebraw:0.6.1": zebraw
 
 #let raw-template-html(body) = {
-  show: show-target(html: hypraw.with(dedup-styles: false, line-numbers: true, copy-button: false))
+  show: it => if (sys.inputs.at("x-renderer", default: "astro-typst") == "tybios") { it } else {
+    show: hypraw.with(dedup-styles: false, line-numbers: true, copy-button: false)
+    it
+  }
   body
 }
 
src/integrations/extract-image.ts
@@ -1,10 +1,10 @@
-import crypto from "node:crypto";
 import fs from "node:fs/promises";
 import path from "node:path";
 import { fileURLToPath } from "node:url";
 import { z } from "astro/zod";
 import { defineIntegration } from "astro-integration-kit";
 import * as cheerio from "cheerio";
+import { getBase64ImageInfo } from "../utils/image.js";
 
 // TODO: After Typst 0.15 published, use jsx and path to auto optimize images in typst
 // See https://github.com/typst/typst/pull/7555
@@ -13,7 +13,7 @@ import * as cheerio from "cheerio";
 export const extractImage = defineIntegration({
   name: "tybios-extract-image",
   optionsSchema: z.object({
-    dir: z.string().optional().default("assets/images/typst"),
+    dir: z.string().optional().default("assets/img/typ"),
   }),
   setup({ options }) {
     return {
@@ -44,20 +44,12 @@ export const extractImage = defineIntegration({
                   const el = articleImages[i];
                   const src = $(el).attr("src") || "";
 
-                  const match = src.match(/^data:image\/([^;]+);base64,(.+)$/);
-                  if (!match) {
+                  const imageInfo = getBase64ImageInfo(src);
+                  if (!imageInfo) {
                     continue;
                   }
 
-                  const ext = match[1] === "svg+xml" ? "svg" : match[1];
-                  const base64Data = match[2];
-
-                  const hash = crypto
-                    .createHash("sha256")
-                    .update(base64Data)
-                    .digest("hex")
-                    .slice(0, 10);
-                  const filename = `${hash}.${ext}`;
+                  const { filename, base64Data } = imageInfo;
 
                   if (!seenImages.has(filename)) {
                     seenImages.add(filename);
@@ -85,6 +77,11 @@ export const extractImage = defineIntegration({
           );
         },
       },
+      api: {
+        get dir() {
+          return options.dir;
+        },
+      },
     };
   },
 });
src/layouts/GlobalLayout.astro
@@ -43,6 +43,13 @@ const {
     <meta property="twitter:title" content={title} />
     <meta property="twitter:description" content={description} />
 
+    <link
+      rel="alternate"
+      type="application/atom+xml"
+      title="Atom Feed"
+      href="/feed.atom"
+    />
+
     <link
       rel="stylesheet"
       href="https://fred-wang.github.io/MathFonts/NewComputerModern/mathfonts.css"
src/pages/feed.atom.ts
@@ -0,0 +1,53 @@
+import { getCollection } from "astro:content";
+import { Feed } from "feed";
+import { SITE_CONFIG } from "@/config";
+import { rehypeExternalLinks } from "@/plugins/rehype-external-links";
+import { renderTypst } from "@/utils/render-typst";
+import { rehypeExtractImage } from "@/plugins/rehype-extract-image";
+
+export async function GET() {
+  const posts = await getCollection("posts");
+  const siteUrl = SITE_CONFIG.website;
+
+  const feed = new Feed({
+    title: SITE_CONFIG.title,
+    description: SITE_CONFIG.description,
+    id: siteUrl,
+    link: siteUrl,
+    language: "en",
+    favicon: `${siteUrl}/favicon.ico`,
+    copyright: `All rights reserved ${new Date().getFullYear()}`,
+    feedLinks: {
+      rss: `${siteUrl}/feed.atom`,
+    },
+    author: {
+      name: SITE_CONFIG.author,
+    },
+  });
+
+  await Promise.all(
+    posts.map(async (post) => {
+      const url = `${siteUrl}/posts/${post.id}`;
+      if (typeof post.filePath !== "string") return;
+
+      feed.addItem({
+        title: post.data.title,
+        id: url,
+        link: url,
+        description: post.data.desc,
+        content: await renderTypst(post.filePath, [
+          rehypeExtractImage,
+          rehypeExternalLinks,
+        ]),
+        author: [{ name: post.data.author || SITE_CONFIG.author }],
+        date: post.data.date,
+      });
+    }),
+  );
+
+  return new Response(feed.atom1(), {
+    headers: {
+      "Content-Type": "application/xml",
+    },
+  });
+}
src/plugins/rehype-extract-image.ts
@@ -0,0 +1,19 @@
+import type { RehypePlugin } from "@astrojs/markdown-remark";
+import { visit } from "unist-util-visit";
+import { getBase64ImageInfo } from "../utils/image.js";
+import { extractImage } from "@/integrations/extract-image.js";
+import { SITE_CONFIG } from "@/config.js";
+
+export const rehypeExtractImage: RehypePlugin = () => (tree) => {
+  visit(tree, "element", (node) => {
+    if (node.tagName === "img") {
+      const src = node.properties?.src;
+      if (typeof src === "string") {
+        const imageInfo = getBase64ImageInfo(src);
+        if (imageInfo) {
+          node.properties.src = `/${extractImage({ dir: SITE_CONFIG.assetDir }).api.dir}/${imageInfo.filename}`;
+        }
+      }
+    }
+  });
+};
src/types/config.d.ts
@@ -21,4 +21,8 @@ export type SiteConfig = {
    * e.g. `"Etc/UTC"`, `"America/New_York"`, `"Asia/Shanghai"`.
    */
   timeZone: string;
+  /**
+   * Output directory of typst images
+   */
+  assetDir?: string;
 };
src/utils/image.ts
@@ -0,0 +1,23 @@
+import crypto from "node:crypto";
+
+export function getBase64ImageInfo(
+  src: string,
+): { filename: string; base64Data: string; ext: string } | null {
+  const match = src.match(/^data:image\/([^;]+);base64,(.+)$/);
+  if (!match) {
+    return null;
+  }
+
+  const ext = match[1] === "svg+xml" ? "svg" : match[1];
+  const base64Data = match[2];
+
+  const hash = crypto
+    .createHash("sha256")
+    .update(base64Data)
+    .digest("hex")
+    .slice(0, 12);
+
+  const filename = `${hash}.${ext}`;
+
+  return { filename, base64Data, ext };
+}
src/utils/render-typst.ts
@@ -0,0 +1,35 @@
+import { root } from "astro:config/server";
+import { fileURLToPath } from "node:url";
+import type { RehypePlugins } from "@astrojs/markdown-remark";
+import { NodeCompiler } from "@myriaddreamin/typst-ts-node-compiler";
+import { rehypeIt } from "astro-typst/dist/lib/rehype";
+import type { Root } from "hast";
+import * as cheerio from "cheerio";
+
+const rootPath = fileURLToPath(root);
+const $typst = NodeCompiler.create({
+  workspace: rootPath,
+  inputs: { "x-renderer": "tybios" },
+});
+
+export async function renderTypst(
+  mainfilePath: string,
+  plugins?: RehypePlugins,
+): Promise<string> {
+  const docRes = $typst.compileHtml({ mainFilePath: mainfilePath });
+  if (!docRes.result) {
+    docRes.printDiagnostics();
+    return "";
+  }
+  const doc = docRes.result;
+  const html = $typst.tryHtml(doc);
+  if (!html.result) {
+    html.printDiagnostics();
+    return "";
+  }
+  const hast = html.result.hast() as Root;
+  const rehypeRes = await rehypeIt(hast, plugins || []);
+  const $ = cheerio.load(rehypeRes.toString());
+
+  return $("body").html() || "";
+}
astro.config.ts
@@ -17,7 +17,7 @@ export default defineConfig({
       target: "html",
     }),
     extractImage({
-      dir: "assets/images/typst",
+      dir: SITE_CONFIG.assetDir,
     }),
   ],
   markdown: {
package.json
@@ -18,12 +18,14 @@
     "astro-integration-kit": "^0.19.1",
     "astro-typst": "^0.12.3",
     "cheerio": "^1.2.0",
+    "feed": "^5.2.0",
     "luxon": "^3.7.2",
     "tailwindcss": "^4.2.1",
     "unist-util-visit": "^5.1.0"
   },
   "devDependencies": {
     "@biomejs/biome": "^2.4.6",
+    "@types/hast": "^3.0.4",
     "@types/luxon": "^3.7.1",
     "@types/node": "^25.4.0"
   }
pnpm-lock.yaml
@@ -35,6 +35,9 @@ importers:
       cheerio:
         specifier: ^1.2.0
         version: 1.2.0
+      feed:
+        specifier: ^5.2.0
+        version: 5.2.0
       luxon:
         specifier: ^3.7.2
         version: 3.7.2
@@ -48,6 +51,9 @@ importers:
       '@biomejs/biome':
         specifier: ^2.4.6
         version: 2.4.6
+      '@types/hast':
+        specifier: ^3.0.4
+        version: 3.0.4
       '@types/luxon':
         specifier: ^3.7.1
         version: 3.7.1
@@ -1157,6 +1163,10 @@ packages:
       picomatch:
         optional: true
 
+  feed@5.2.0:
+    resolution: {integrity: sha512-hgH6CCb+7+0c8PBlakI2KubG6R+Rb1MhpNcdvqUXZTBwBHf32piwY255diAkAmkGZ6AWlywOU88AkOgP9q8Rdw==}
+    engines: {node: '>=20', pnpm: '>=10'}
+
   flattie@1.1.1:
     resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==}
     engines: {node: '>=8'}
@@ -2043,6 +2053,10 @@ packages:
     resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==}
     engines: {node: '>=4'}
 
+  xml-js@1.6.11:
+    resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==}
+    hasBin: true
+
   xxhash-wasm@1.1.0:
     resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==}
 
@@ -3105,6 +3119,10 @@ snapshots:
     optionalDependencies:
       picomatch: 4.0.3
 
+  feed@5.2.0:
+    dependencies:
+      xml-js: 1.6.11
+
   flattie@1.1.1: {}
 
   fontace@0.4.1:
@@ -4408,6 +4426,10 @@ snapshots:
 
   which-pm-runs@1.1.0: {}
 
+  xml-js@1.6.11:
+    dependencies:
+      sax: 1.5.0
+
   xxhash-wasm@1.1.0: {}
 
   yargs-parser@22.0.0: {}